diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs
index 7b401557..89b82c4e 100644
--- a/helix-core/src/lib.rs
+++ b/helix-core/src/lib.rs
@@ -8,6 +8,7 @@ pub mod macros;
 pub mod object;
 mod position;
 pub mod register;
+pub mod search;
 pub mod selection;
 pub mod state;
 pub mod syntax;
diff --git a/helix-core/src/search.rs b/helix-core/src/search.rs
index 7d790d66..c03f60df 100644
--- a/helix-core/src/search.rs
+++ b/helix-core/src/search.rs
@@ -1,69 +1,39 @@
 use crate::RopeSlice;
 
-pub fn find_nth_next(text: RopeSlice, ch: char, pos: usize, n: usize) -> Option<usize> {
+pub fn find_nth_next(text: RopeSlice, ch: char, mut pos: usize, n: usize) -> Option<usize> {
     // start searching right after pos
-    let mut byte_idx = text.char_to_byte(pos + 1);
-
-    let (mut chunks, mut chunk_byte_idx, _chunk_char_idx, _chunk_line_idx) =
-        text.chunks_at_byte(byte_idx);
-
-    let mut chunk = chunks.next().unwrap_or("");
-
-    chunk = &chunk[(byte_idx - chunk_byte_idx)..];
+    let mut chars = text.chars_at(pos + 1);
 
     for _ in 0..n {
         loop {
-            match chunk.find(ch) {
-                Some(pos) => {
-                    byte_idx += pos;
-                    chunk = &chunk[pos + 1..];
-                    break;
-                }
-                None => match chunks.next() {
-                    Some(next_chunk) => {
-                        byte_idx += chunk.len();
-                        chunk = next_chunk;
-                    }
-                    None => {
-                        log::info!("no more chunks");
-                        return None;
-                    }
-                },
+            let c = chars.next()?;
+
+            pos += 1;
+
+            if c == ch {
+                break;
             }
         }
     }
-    Some(text.byte_to_char(byte_idx))
+
+    Some(pos)
 }
 
-pub fn find_nth_prev(text: RopeSlice, ch: char, pos: usize, n: usize) -> Option<usize> {
+pub fn find_nth_prev(text: RopeSlice, ch: char, mut pos: usize, n: usize) -> Option<usize> {
     // start searching right before pos
-    let mut byte_idx = text.char_to_byte(pos.saturating_sub(1));
-
-    let (mut chunks, mut chunk_byte_idx, _chunk_char_idx, _chunk_line_idx) =
-        text.chunks_at_byte(byte_idx);
-
-    let mut chunk = chunks.prev().unwrap_or("");
-
-    // start searching from pos
-    chunk = &chunk[..=byte_idx - chunk_byte_idx];
+    let mut chars = text.chars_at(pos.saturating_sub(1));
 
     for _ in 0..n {
         loop {
-            match chunk.rfind(ch) {
-                Some(pos) => {
-                    byte_idx = chunk_byte_idx + pos;
-                    chunk = &chunk[..pos];
-                    break;
-                }
-                None => match chunks.prev() {
-                    Some(prev_chunk) => {
-                        chunk_byte_idx -= chunk.len();
-                        chunk = prev_chunk;
-                    }
-                    None => return None,
-                },
+            let c = chars.prev()?;
+
+            pos = pos.saturating_sub(1);
+
+            if c == ch {
+                break;
             }
         }
     }
-    Some(text.byte_to_char(byte_idx))
+
+    Some(pos)
 }
diff --git a/helix-core/src/state.rs b/helix-core/src/state.rs
index d2ebca47..8ff86f0c 100644
--- a/helix-core/src/state.rs
+++ b/helix-core/src/state.rs
@@ -123,6 +123,8 @@ impl State {
     pub fn move_next_word_start(slice: RopeSlice, mut pos: usize, count: usize) -> usize {
         // TODO: confirm it's fine without using graphemes, I think it should be
         for _ in 0..count {
+            // TODO: if end return end
+
             let ch = slice.char(pos);
             let next = slice.char(pos.saturating_add(1));
             if categorize(ch) != categorize(next) {
@@ -148,8 +150,12 @@ impl State {
     pub fn move_prev_word_start(slice: RopeSlice, mut pos: usize, count: usize) -> usize {
         // TODO: confirm it's fine without using graphemes, I think it should be
         for _ in 0..count {
+            if pos == 0 {
+                return pos;
+            }
+
             let ch = slice.char(pos);
-            let prev = slice.char(pos.saturating_sub(1)); // TODO: just return original pos if at start
+            let prev = slice.char(pos - 1);
 
             if categorize(ch) != categorize(prev) {
                 pos -= 1;
@@ -176,6 +182,8 @@ impl State {
 
     pub fn move_next_word_end(slice: RopeSlice, mut pos: usize, count: usize) -> usize {
         for _ in 0..count {
+            // TODO: if end return end
+
             // TODO: confirm it's fine without using graphemes, I think it should be
             let ch = slice.char(pos);
             let next = slice.char(pos.saturating_add(1));
@@ -303,7 +311,7 @@ where
         if !fun(ch) {
             break;
         }
-        *pos += 1;
+        *pos += 1; // TODO: can go 1 over end of doc
     }
 }
 
@@ -319,7 +327,7 @@ where
         if !fun(ch) {
             break;
         }
-        *pos -= 1;
+        *pos -= pos.saturating_sub(1);
     }
 }
 
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 47a61b7f..f60f646e 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -3,7 +3,7 @@ use helix_core::{
     indent::TAB_WIDTH,
     object,
     regex::{self, Regex},
-    register, selection,
+    register, search, selection,
     state::{coords_at_pos, pos_at_coords, Direction, Granularity, State},
     Change, ChangeSet, Position, Range, Selection, Tendril, Transaction,
 };
@@ -19,12 +19,15 @@ use helix_view::{
     Document, Editor,
 };
 
+use crossterm::event::{KeyCode, KeyEvent};
+
 pub struct Context<'a> {
     pub count: usize,
     pub editor: &'a mut Editor,
     pub executor: &'static smol::Executor<'static>,
 
     pub callback: Option<crate::compositor::Callback>,
+    pub on_next_key_callback: Option<Box<dyn FnOnce(&mut Context, KeyEvent)>>,
 }
 
 impl<'a> Context<'a> {
@@ -49,6 +52,14 @@ impl<'a> Context<'a> {
             },
         ));
     }
+
+    #[inline]
+    pub fn on_next_key(
+        &mut self,
+        on_next_key_callback: impl FnOnce(&mut Context, KeyEvent) + 'static,
+    ) {
+        self.on_next_key_callback = Some(Box::new(on_next_key_callback));
+    }
 }
 
 /// A command is a function that takes the current state and a count, and does a side-effect on the
@@ -225,6 +236,36 @@ pub fn extend_next_word_end(cx: &mut Context) {
     doc.set_selection(selection);
 }
 
+pub fn find_next_char(cx: &mut Context) {
+    // TODO: count is reset to 1 before next key so we move it into the closure here.
+    // Would be nice to carry over.
+    let count = cx.count;
+
+    // need to wait for next key
+    cx.on_next_key(move |cx, event| {
+        if let KeyEvent {
+            code: KeyCode::Char(ch),
+            ..
+        } = event
+        {
+            let doc = cx.doc();
+            let text = doc.text().slice(..);
+
+            let selection = doc.selection().transform(|mut range| {
+                if let Some(pos) = search::find_nth_next(text, ch, range.head, count) {
+                    Range::new(range.head, pos)
+                    // or (range.anchor, pos) for extend
+                    // or (pos, pos) to move to found val
+                } else {
+                    range
+                }
+            });
+
+            doc.set_selection(selection);
+        }
+    })
+}
+
 fn scroll(view: &mut View, offset: usize, direction: Direction) {
     use Direction::*;
     let text = view.doc.text().slice(..);
diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs
index 3be92fcc..d956679a 100644
--- a/helix-term/src/keymap.rs
+++ b/helix-term/src/keymap.rs
@@ -138,7 +138,7 @@ pub fn default() -> Keymaps {
         key!('l') => commands::move_char_right,
 
         // key!('t') => commands::till_next_char,
-        // key!('f') => commands::find_next_char,
+        key!('f') => commands::find_next_char,
         // key!('T') => commands::till_prev_char,
         // key!('f') => commands::find_prev_char,
         // and matching set for select mode (extend)
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 0bfc1a33..7a5e4aa5 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -20,6 +20,7 @@ use tui::{
 
 pub struct EditorView {
     keymap: Keymaps,
+    on_next_key: Option<Box<dyn FnOnce(&mut commands::Context, KeyEvent)>>,
 }
 
 const OFFSET: u16 = 7; // 1 diagnostic + 5 linenr + 1 gutter
@@ -28,6 +29,7 @@ impl EditorView {
     pub fn new() -> Self {
         Self {
             keymap: keymap::default(),
+            on_next_key: None,
         }
     }
     pub fn render_view(
@@ -366,46 +368,55 @@ impl Component for EditorView {
                     editor: &mut cx.editor,
                     count: 1,
                     callback: None,
+                    on_next_key_callback: None,
                 };
 
-                match mode {
-                    Mode::Insert => {
-                        if let Some(command) = self.keymap[&Mode::Insert].get(&event) {
-                            command(&mut cxt);
-                        } else if let KeyEvent {
-                            code: KeyCode::Char(c),
-                            ..
-                        } = event
-                        {
-                            commands::insert::insert_char(&mut cxt, c);
-                        }
-                    }
-                    mode => {
-                        match event {
-                            KeyEvent {
-                                code: KeyCode::Char(i @ '0'..='9'),
-                                modifiers: KeyModifiers::NONE,
-                            } => {
-                                let i = i.to_digit(10).unwrap() as usize;
-                                cxt.editor.count = Some(cxt.editor.count.map_or(i, |c| c * 10 + i));
+                if let Some(on_next_key) = self.on_next_key.take() {
+                    // if there's a command waiting input, do that first
+                    on_next_key(&mut cxt, event);
+                } else {
+                    match mode {
+                        Mode::Insert => {
+                            if let Some(command) = self.keymap[&Mode::Insert].get(&event) {
+                                command(&mut cxt);
+                            } else if let KeyEvent {
+                                code: KeyCode::Char(c),
+                                ..
+                            } = event
+                            {
+                                commands::insert::insert_char(&mut cxt, c);
                             }
-                            _ => {
-                                // set the count
-                                cxt.count = cxt.editor.count.take().unwrap_or(1);
-                                // TODO: edge case: 0j -> reset to 1
-                                // if this fails, count was Some(0)
-                                // debug_assert!(cxt.count != 0);
+                        }
+                        mode => {
+                            match event {
+                                KeyEvent {
+                                    code: KeyCode::Char(i @ '0'..='9'),
+                                    modifiers: KeyModifiers::NONE,
+                                } => {
+                                    let i = i.to_digit(10).unwrap() as usize;
+                                    cxt.editor.count =
+                                        Some(cxt.editor.count.map_or(i, |c| c * 10 + i));
+                                }
+                                _ => {
+                                    // set the count
+                                    cxt.count = cxt.editor.count.take().unwrap_or(1);
+                                    // TODO: edge case: 0j -> reset to 1
+                                    // if this fails, count was Some(0)
+                                    // debug_assert!(cxt.count != 0);
 
-                                if let Some(command) = self.keymap[&mode].get(&event) {
-                                    command(&mut cxt);
+                                    if let Some(command) = self.keymap[&mode].get(&event) {
+                                        command(&mut cxt);
 
-                                    // TODO: simplistic ensure cursor in view for now
+                                        // TODO: simplistic ensure cursor in view for now
+                                    }
                                 }
                             }
                         }
                     }
                 }
 
+                self.on_next_key = cxt.on_next_key_callback.take();
+
                 // appease borrowck
                 let callback = cxt.callback.take();
                 drop(cxt);