From b3a60aa361449934dc0cd54831a11bc0b0ce48d7 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 15:16:00 +0200 Subject: [PATCH] keys: the command line remembers and completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `↑` / `↓` (and `Ctrl-p` / `Ctrl-n`) walk the lines run before, newest first, narrowed by what is already typed: `:se` then `↑` finds the last `:set …`. Commands and `/` searches keep separate histories in localStorage, a hundred lines each. Passwords never get here: they are typed into the vault prompt, not the line. Tab moves to the next match and Shift-Tab to the one before, with the match marked in the list. Once a name is complete, Tab completes the argument: the folders listed here plus `..` and `/` for `:cd` and `:mv`, space names for `:space` and `:rmspace`, the options of `:set`, `:due`'s usual dates and `:backup`'s subcommands. `:mv /` now moves to the root of the space, as `:cd /` goes there. While a search is live, `Ctrl-n` / `Ctrl-p` (and Tab) move the list cursor through the results without leaving the line, and Enter opens the one lit rather than only closing the line. Ctrl-P's search does the same. After a command the focus goes back where it was, or to the editor while writing, instead of being left on for the window handler to catch. --- README.md | 6 +- web/src/lib/CommandLine.svelte | 190 ++++++++++++++++++++++++++++++--- web/src/lib/Help.svelte | 6 +- web/src/lib/commands.ts | 43 +++++++- 4 files changed, 220 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f87a9f4..38b64ee 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,11 @@ it; in the document pane it is that document. In the browse pane: | `c` | capture a card into this space's inbox without leaving where you are | | `Esc`, `q` | close the document | | `h` | up one level in the tree | -| `/` | full-text search in this space (`n` clears it) | +| `/` | full-text search in this space (`n` clears it); `Tab` or `Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens the one lit | | `Ctrl-P` | full-text search across every space; `Enter` goes to the match | | `s` / `y` | toggle sharing / mint a new share link — either asks first when it would cut readers off | | `x`, `dd` | delete the entry under the cursor (asks `y`/`N`) | -| `:` | command line | +| `:` | command line — `Tab` / `Shift-Tab` cycle the matching commands, then the argument (folders for `:cd` and `:mv`, spaces for `:space`, options for `:set` …); `↑` / `↓` recall the lines run before that start as this one does | | `Ctrl-S` | save from anywhere | | `?` | help | @@ -117,7 +117,7 @@ and lines, `:search!` searches every space, `:set clipboard` / `:set noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the session, `:logout!` every session and `:sessions rm ` one other. The tree has its own: `:space [name]` switches (`:space! ` makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir ` -makes a folder, `:cd ` walks it and `:mv ` moves what is +makes a folder, `:cd ` walks it and `:mv ` moves what is selected, and `:import` uploads a folder into the one being browsed (`:import!` takes a `.zip` instead). `:upload` outside the editor puts files into the folder being browsed, and so does dropping them on the list. `:name ` renames whatever is under diff --git a/web/src/lib/CommandLine.svelte b/web/src/lib/CommandLine.svelte index a8162e2..6be78e4 100644 --- a/web/src/lib/CommandLine.svelte +++ b/web/src/lib/CommandLine.svelte @@ -1,6 +1,34 @@ + +
- {#if matches.length > 0 && value.replace(/^:/, '').length > 0} -
    - {#each matches as spec (spec.name)} + {#if shown.length > 0} +
      + {#each shown as item, index (item.value)}
    • -
    • {/each} @@ -70,7 +227,7 @@
      {#if isSearch}{global ? 'all spaces' : (workspace.space?.slug ?? '')}{/if} - +
@@ -130,6 +287,9 @@ text-transform: none; } .completions button:hover { background: var(--surface-alt); } + .completions button.current { background: var(--light); color: var(--ink); } + .completions button.current .name, + .completions button.current .help { color: var(--ink); } .name { color: var(--light); } .help { color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index c7fda87..c6caa57 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -28,16 +28,16 @@ ['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'], ['t', 'agenda — late, due this week, in hand, on every board'], ['T', "today's diary entry, started if there is none"], - ['Ctrl-P', 'find anything, in every space'], + ['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'], ['b', "open this space's board"], ['c', "capture a card into this space's inbox, without leaving here"], ['Esc · q', 'close the document'], - ['/', 'full-text search, inside the current space'], + ['/', 'full-text search, inside the current space — Tab or Ctrl-N/P walk the results, Enter opens one'], ['n', 'clear the search'], ['s', 'toggle sharing for the document — asks before taking it back'], ['y', 'mint a new share link and copy it — asks, the old one stops working'], ['x · dd', 'delete what has focus — the row in the list, the document in its pane (asks y/N)'], - [':', 'command line'], + [':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'], ['Ctrl-S', 'save, from anywhere'], ['?', 'this screen — in the editor too'], ] diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 64116a3..03fb0aa 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -51,6 +51,8 @@ export interface CommandSpec { group: Group help: string run: (arg: string) => void | Promise + /** What the argument can be, for Tab on the command line. */ + complete?: (arg: string) => string[] /** The forcing variant, `:name!` — vim hands the bang over as an argument. */ bang?: { help: string; run: (arg: string) => void | Promise } } @@ -93,6 +95,15 @@ const findFolder = (target: string) => { ) } +/** The folders listed here, by slug, then the two ways out — for `:cd` and `:mv`. */ +const folderNames = () => [ + ...workspace.nodes.filter(isContainer).map((node) => node.slug), + '..', + '/', +] + +const spaceNames = () => workspace.spaces.map((space) => space.slug) + /** `:encrypt` and `:decrypt`: a file, or everything under a folder. */ const sealTarget = (encrypt: boolean) => workspace.guard(async () => { @@ -287,6 +298,7 @@ export const commands: CommandSpec[] = [ args: '[name]', group: 'tree', help: 'switch to a space, or list them', + complete: spaceNames, run: async (arg) => { const wanted = arg.trim().toLowerCase() if (!wanted) { @@ -321,6 +333,7 @@ export const commands: CommandSpec[] = [ args: '', group: 'tree', help: 'enter a folder, .. goes up, / back to the space', + complete: folderNames, run: async (arg) => { const target = arg.trim() await workspace.guard(async () => { @@ -382,6 +395,7 @@ export const commands: CommandSpec[] = [ args: '', group: 'board', help: 'due date of the card: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears', + complete: () => ['today', 'tm', 'fri', '+3d'], run: async (arg) => { if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error') const target = arg.trim() @@ -428,14 +442,15 @@ export const commands: CommandSpec[] = [ { name: 'move', aliases: ['mv'], - args: '', + args: '', group: 'tree', - help: 'move the marked nodes, or else the selected one, into a folder here, or up one level', + help: 'move the marked nodes, or else the selected one, into a folder here, up one level, or to the root of the space', + complete: folderNames, run: async (arg) => { const node = workspace.marked[0] ?? workspace.target if (!node) return workspace.say('nothing selected', 'error') const target = arg.trim() - if (!target) return workspace.say('usage: :mv ', 'error') + if (!target) return workspace.say('usage: :mv ', 'error') // `..` is up from where the node is, which in a search result is not // the folder being browsed. @@ -444,7 +459,8 @@ export const commands: CommandSpec[] = [ const parent = await api.getNode(node.parent_id) return parent.kind === 'space' ? undefined : (parent.parent_id ?? undefined) } - const destination = target === '..' ? await up() : findFolder(target)?.id + const destination = + target === '..' ? await up() : target === '/' ? (workspace.spaceId ?? undefined) : findFolder(target)?.id if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error') if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error') if (workspace.marked.length) return workspace.guard(() => workspace.moveNodes(workspace.marked, destination)) @@ -651,6 +667,7 @@ export const commands: CommandSpec[] = [ args: '[name]', group: 'tree', help: 'delete a space and everything in it (asks first)', + complete: spaceNames, run: async (arg) => { const wanted = arg.trim().toLowerCase() const space = wanted @@ -878,6 +895,7 @@ export const commands: CommandSpec[] = [ args: '