@vincemakes/kiso-tui 0.46.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,8 @@
6
6
  * one band, in an order the editor keeps in one place: the panel first,
7
7
  * then the session picker, then the composer's own bands. What the
8
8
  * panel may do to the composer is the `BandHost` surface and nothing
9
- * else: it types its amend reason, its custom ask answer and its custom
10
- * pick into the composer, stashes the composer at open, restores it at
9
+ * else: it types its amend reason, its custom ask answer and a pick's
10
+ * filter into the composer, stashes the composer at open, restores it at
11
11
  * close — that is the whole coupling, and it is visible here.
12
12
  *
13
13
  * DECLARED MOVE (S5, 2026-09-06): every method below stood in editor.ts
@@ -16,7 +16,7 @@
16
16
  * and the mouse hit. The bodies are the same; only the buffer access
17
17
  * goes through the host.
18
18
  */
19
- import { PICK_MAX, panelOptions, pickWindow, saferDegradedNote, startLevel, stepLevel, } from "./approval-panel.js";
19
+ import { panelOptions, pickList, pickWindowOf, saferDegradedNote, startLevel, stepLevel, } from "./approval-panel.js";
20
20
  import { askCommitCustom, askKey, askOnCustomRow, askStart } from "./ask-panel.js";
21
21
  export class PanelInput {
22
22
  host;
@@ -52,7 +52,7 @@ export class PanelInput {
52
52
  safer: opts?.safer,
53
53
  saferRun: null,
54
54
  ask: view.ask === undefined ? null : askStart(view.ask),
55
- pick: view.pick === undefined ? null : { cursor: start, phase: "options", level: startLevel(view.pick.options[start]) },
55
+ pick: view.pick === undefined ? null : { cursor: start, level: startLevel(view.pick.options[start]) },
56
56
  onCommit,
57
57
  stash: this.host.stash(),
58
58
  };
@@ -78,9 +78,20 @@ export class PanelInput {
78
78
  ...(panel.note === null ? {} : { note: panel.note }),
79
79
  ...(panel.saferRun === null ? {} : { safer: panel.saferRun }),
80
80
  ...(panel.ask === null ? {} : { ask: panel.ask }),
81
- ...(panel.pick === null ? {} : { pick: panel.pick }),
81
+ ...(panel.pick === null ? {} : { pick: this.#pickNow(panel.view, panel.pick) }),
82
82
  };
83
83
  }
84
+ /** The pick's runtime as the renderer and the keys read it (Graphite
85
+ * P3): a filter's query is the composer's text, read now, and the
86
+ * cursor and level are the CORRECTED ones — a row the filter took away
87
+ * is never reported as the selection. */
88
+ #pickNow(view, pick) {
89
+ const now = view.pick?.input === "filter" ? { ...pick, query: this.host.line() } : pick;
90
+ if (view.pick === undefined)
91
+ return now;
92
+ const list = pickList(view.pick, now);
93
+ return { ...now, cursor: list.cursor, level: list.level };
94
+ }
84
95
  /**
85
96
  * A parsed key while the panel is up. Returns the bytes it consumed,
86
97
  * or null when the key falls through to the composer — the amend
@@ -97,14 +108,16 @@ export class PanelInput {
97
108
  return null;
98
109
  const enterBytes = key.kind === "enter" && key.crlf ? 2 : 1;
99
110
  if (panel.pick !== null) {
100
- const typing = panel.pick.phase === "custom";
111
+ const filter = panel.view.pick?.input === "filter";
101
112
  if (key.kind === "esc") {
102
- this.#pickPanelEsc();
113
+ this.#close({ action: "cancel" });
103
114
  return 1;
104
115
  }
105
116
  if (key.kind === "enter") {
106
117
  if (pasting) {
107
- this.host.newline();
118
+ // a pasted line break is not a pick, and a filter is one line
119
+ if (!filter)
120
+ this.host.newline();
108
121
  return enterBytes;
109
122
  }
110
123
  this.#pickPanelEnter();
@@ -112,20 +125,27 @@ export class PanelInput {
112
125
  }
113
126
  if (key.kind === "char") {
114
127
  const c = key.ch;
115
- if (!typing && c >= "1" && c <= "9") {
116
- this.#pickPanelDigit(Number(c) - 1);
117
- return 1;
128
+ // Graphite P3: a filter takes every printable key — a digit or an
129
+ // `h` is a letter of a model name there. Backspace and the
130
+ // composer's other editing keys fall through to the composer,
131
+ // and the list re-derives from its text on the next read.
132
+ if (filter) {
133
+ if (c >= " " && c !== "\x7f") {
134
+ this.host.insert(c.codePointAt(0));
135
+ this.host.render();
136
+ return 1;
137
+ }
118
138
  }
119
- if (!typing && (c === "h" || c === "l") && this.#levelStep(c === "h" ? -1 : 1))
120
- return 1;
121
- if (!typing && (c === "t" || c === "T") && panel.view.pick?.typeHint !== undefined) {
122
- panel.pick = { cursor: panel.pick.cursor, phase: "custom", level: panel.pick.level };
123
- this.host.clear();
124
- this.host.render();
125
- return 1;
139
+ else {
140
+ if ((panel.view.pick?.input ?? "digits") === "digits" && c >= "1" && c <= "9") {
141
+ this.#pickPanelDigit(Number(c) - 1);
142
+ return 1;
143
+ }
144
+ if ((c === "h" || c === "l") && this.#levelStep(c === "h" ? -1 : 1))
145
+ return 1;
146
+ if (c >= " " && c !== "\x7f")
147
+ return 1;
126
148
  }
127
- if (!typing && c >= " " && c !== "\x7f")
128
- return 1;
129
149
  }
130
150
  }
131
151
  if (panel.ask !== null) {
@@ -208,19 +228,20 @@ export class PanelInput {
208
228
  const panel = this.#panel;
209
229
  if (panel === null)
210
230
  return false;
211
- if (panel.pick !== null && panel.pick.phase === "options") {
231
+ if (panel.pick !== null) {
212
232
  // DC-58: EVERY option is reachable by ↑↓ — the window follows the
213
- // cursor (`pickWindow`) and PICK_MAX is only how many rows one
214
- // screen holds. This bound used to be `min(count, PICK_MAX)`, which
215
- // is what left a 60-profile config with nine reachable rows.
216
- const n = panel.view.pick.options.length;
217
- const cur = panel.pick.cursor;
218
- const next = dir === "up" ? Math.max(0, cur - 1) : Math.min(Math.max(0, n - 1), cur + 1);
233
+ // cursor. P3: the walk is over what the list SHOWS (a filter's
234
+ // matches, then the direct row), so ↑↓ never lands on a row the
235
+ // person cannot see.
236
+ const list = pickList(panel.view.pick, this.#pickNow(panel.view, panel.pick));
237
+ const at = list.shown.indexOf(list.cursor);
238
+ const to = list.shown[dir === "up" ? Math.max(0, at - 1) : Math.min(list.shown.length - 1, at + 1)];
219
239
  // OR-7: the second axis belongs to the highlighted option, so it
220
240
  // re-lands on THAT option's own level. Carrying the previous
221
241
  // row's index across would point at a level this model may not
222
242
  // have — the silent clamp the coordination note exists to end.
223
- panel.pick = { cursor: next, phase: "options", level: startLevel(panel.view.pick.options[next]) };
243
+ if (to !== undefined)
244
+ panel.pick = { cursor: to, level: startLevel(panel.view.pick.options[to]) };
224
245
  }
225
246
  else if (panel.ask !== null && panel.ask.phase === "options")
226
247
  this.#askStep(dir);
@@ -252,12 +273,13 @@ export class PanelInput {
252
273
  * no axis to walk, which is what leaves the key to the composer. */
253
274
  #levelStep(dir) {
254
275
  const panel = this.#panel;
255
- if (panel === null || panel.pick === null || panel.pick.phase !== "options")
276
+ if (panel === null || panel.pick === null)
256
277
  return false;
257
- const o = panel.view.pick?.options[panel.pick.cursor];
258
- if (o?.levels === undefined)
278
+ const list = pickList(panel.view.pick, this.#pickNow(panel.view, panel.pick));
279
+ const o = panel.view.pick.options[list.cursor];
280
+ if (o?.levels === undefined || o.levels.length === 0)
259
281
  return false;
260
- panel.pick = { cursor: panel.pick.cursor, phase: "options", level: stepLevel(o, panel.pick.level, dir) };
282
+ panel.pick = { cursor: list.cursor, level: stepLevel(o, list.level, dir) };
261
283
  this.host.render();
262
284
  return true;
263
285
  }
@@ -455,47 +477,35 @@ export class PanelInput {
455
477
  const panel = this.#panel;
456
478
  if (panel === null || panel.pick === null)
457
479
  return;
458
- const count = panel.view.pick.options.length;
459
480
  // DC-58: a digit names the row ON SCREEN. The window's origin is what
460
- // turns a visible position into an option index — the same
461
- // `pickWindow` the renderer drew the rows with, so what the row says
462
- // and what the key does cannot drift apart.
463
- const { first, size } = this.host.pickWindow?.() ?? pickWindow(panel.pick.cursor, count, PICK_MAX);
481
+ // turns a visible position into an option — the same window the
482
+ // renderer drew the rows with, so what the row says and what the key
483
+ // does cannot drift apart.
484
+ const now = this.#pickNow(panel.view, panel.pick);
485
+ const { first, size } = this.host.pickWindow?.() ?? pickWindowOf(panel.view, now, Number.MAX_SAFE_INTEGER, process.stdout.rows ?? 24);
464
486
  if (index < 0 || index >= size)
465
487
  return;
466
- const target = first + index;
467
- if (target >= count)
488
+ const target = pickList(panel.view.pick, now).shown[first + index];
489
+ if (target === undefined || target >= panel.view.pick.options.length)
468
490
  return;
469
- panel.pick = { cursor: target, phase: "options", level: startLevel(panel.view.pick.options[target]) };
491
+ panel.pick = { cursor: target, level: startLevel(panel.view.pick.options[target]) };
470
492
  this.host.render();
471
493
  }
472
494
  #pickPanelEnter() {
473
495
  const panel = this.#panel;
474
496
  if (panel === null || panel.pick === null)
475
497
  return;
476
- if (panel.pick.phase === "custom") {
477
- const line = this.host.line().trim();
478
- if (line === "")
479
- return;
480
- this.#close({ action: "picked", result: { custom: line } });
481
- return;
482
- }
483
- if (panel.view.pick.options.length === 0)
498
+ const list = pickList(panel.view.pick, this.#pickNow(panel.view, panel.pick));
499
+ if (list.cursor < 0)
484
500
  return; // nothing to take
485
- const level = panel.pick.level;
486
- this.#close({ action: "picked", result: { index: panel.pick.cursor }, ...(level === null ? {} : { level }) });
487
- }
488
- #pickPanelEsc() {
489
- const panel = this.#panel;
490
- if (panel === null || panel.pick === null)
491
- return;
492
- if (panel.pick.phase === "custom") {
493
- panel.pick = { cursor: panel.pick.cursor, phase: "options", level: panel.pick.level };
494
- this.host.clear();
495
- this.host.render();
501
+ // P3: the direct row hands back what was typed — the old `t` row's result
502
+ if (list.cursor === panel.view.pick.options.length) {
503
+ if (list.direct !== null)
504
+ this.#close({ action: "picked", result: { custom: list.direct } });
496
505
  return;
497
506
  }
498
- this.#close({ action: "cancel" });
507
+ const level = list.level;
508
+ this.#close({ action: "picked", result: { index: list.cursor }, ...(level === null ? {} : { level }) });
499
509
  }
500
510
  /** Close: the state is cleared FIRST, the stashed composer comes
501
511
  * back, the Enter that closed the panel is swallowed, and only then
@@ -1,3 +1,22 @@
1
+ /**
2
+ * The SESSION PICKER's input controller — S5 (finding C10). TUI2-R2 ②:
3
+ * the band's third occupant. Its filter is the @ picker's rank aimed at
4
+ * the session id; this controller owns the keys, the compositor draws
5
+ * the rows.
6
+ *
7
+ * The picker is MODAL in a way the @ picker is not: it opens before a
8
+ * session exists, owns the whole composer (the buffer IS the filter
9
+ * query, derived on every read rather than stored, so every buffer op —
10
+ * backspace, the kills, paste — filters correctly with no handler of
11
+ * its own), and the only ways out are a pick and an esc. That is why
12
+ * the commit callback lives here rather than on the line channel: the
13
+ * caller is waiting for an id, not for a turn.
14
+ *
15
+ * DECLARED MOVE (S5, 2026-09-06): `beginPick`, `#pickView`, `pickState`,
16
+ * `#pickUp`, `#pickRows`, `#pickClose`, `#pickAccept` and the ↑↓ branch
17
+ * stood in editor.ts; the bodies are the same, the buffer access goes
18
+ * through the host.
19
+ */
1
20
  import type { BandHost } from "./panel-input.js";
2
21
  import { type SessionCardView, type SessionPickState } from "./session-picker.js";
3
22
  export declare class PickInput {
@@ -20,9 +39,10 @@ export declare class PickInput {
20
39
  * key (the buffer IS the query), so the toggle cannot be one. True when
21
40
  * the picker owned the key. */
22
41
  toggleScope(): boolean;
23
- /** The band's height estimate: the header + the windowed rows (or
24
- * the one "no match" row) + the counter. */
25
- rows(): number;
42
+ /** The band's height estimate: the header, the windowed rows and the
43
+ * selected row's opened second row (or the one "no match" row), and
44
+ * the key row — sessionPickerRows' shape at this terminal height. */
45
+ rows(height: number): number;
26
46
  /** ↑↓: the selection walks the matches and stops at both ends. True
27
47
  * when the picker owned the key (the caller renders). */
28
48
  arrow(dir: "up" | "down"): boolean;
@@ -1,24 +1,4 @@
1
- /**
2
- * The SESSION PICKER's input controller — S5 (finding C10). TUI2-R2 ②:
3
- * the band's third occupant. Its filter is the @ picker's rank aimed at
4
- * the session id; this controller owns the keys, the compositor draws
5
- * the rows.
6
- *
7
- * The picker is MODAL in a way the @ picker is not: it opens before a
8
- * session exists, owns the whole composer (the buffer IS the filter
9
- * query, derived on every read rather than stored, so every buffer op —
10
- * backspace, the kills, paste — filters correctly with no handler of
11
- * its own), and the only ways out are a pick and an esc. That is why
12
- * the commit callback lives here rather than on the line channel: the
13
- * caller is waiting for an id, not for a turn.
14
- *
15
- * DECLARED MOVE (S5, 2026-09-06): `beginPick`, `#pickView`, `pickState`,
16
- * `#pickUp`, `#pickRows`, `#pickClose`, `#pickAccept` and the ↑↓ branch
17
- * stood in editor.ts; the bodies are the same, the buffer access goes
18
- * through the host.
19
- */
20
- import { AT_VISIBLE } from "./at-picker.js";
21
- import { scopeSessions, sessionFilter } from "./session-picker.js";
1
+ import { resumeVisible, scopeSessions, sessionFilter } from "./session-picker.js";
22
2
  export class PickInput {
23
3
  host;
24
4
  #cards = null;
@@ -61,8 +41,9 @@ export class PickInput {
61
41
  // title says is showing, never reaches past it
62
42
  const scoped = this.#here === undefined ? null : scopeSessions(this.#cards(), this.#here, this.#all);
63
43
  const cards = scoped === null ? this.#cards() : scoped.cards;
64
- const matches = sessionFilter(cards, this.host.line());
65
- return { cards, matches, selected: Math.max(0, Math.min(this.#sel, matches.length - 1)), scope: scoped?.scope ?? null };
44
+ const query = this.host.line();
45
+ const matches = sessionFilter(cards, query);
46
+ return { cards, matches, selected: Math.max(0, Math.min(this.#sel, matches.length - 1)), scope: scoped?.scope ?? null, query };
66
47
  }
67
48
  /** 0.40.0 — tab flips CURRENT ↔ ALL. The filter owns every printable
68
49
  * key (the buffer IS the query), so the toggle cannot be one. True when
@@ -77,15 +58,14 @@ export class PickInput {
77
58
  this.host.render();
78
59
  return true;
79
60
  }
80
- /** The band's height estimate: the header + the windowed rows (or
81
- * the one "no match" row) + the counter. */
82
- rows() {
61
+ /** The band's height estimate: the header, the windowed rows and the
62
+ * selected row's opened second row (or the one "no match" row), and
63
+ * the key row — sessionPickerRows' shape at this terminal height. */
64
+ rows(height) {
83
65
  const view = this.state();
84
66
  if (view === null)
85
67
  return 0;
86
- // 0.40.1: + the unknown-workspace row, when CURRENT shows it
87
- const unknownRow = view.scope != null && !view.scope.all && view.scope.unknown > 0 ? 1 : 0;
88
- return Math.min(Math.max(view.matches.length, 1), AT_VISIBLE) + 2 + unknownRow;
68
+ return view.matches.length === 0 ? 3 : Math.min(view.matches.length, resumeVisible(height)) + 3;
89
69
  }
90
70
  /** ↑↓: the selection walks the matches and stops at both ends. True
91
71
  * when the picker owned the key (the caller renders). */
@@ -62,8 +62,14 @@ export declare function scopeSessions(cards: readonly SessionCardView[], here: s
62
62
  readonly cards: readonly SessionCardView[];
63
63
  readonly scope: PickScopeState;
64
64
  };
65
- /** The band's title — the scope and both counts, and the key that flips it. */
66
- export declare function scopeTitle(scope: PickScopeState | null): string;
65
+ /** The band's title — named after the command that opened it, then the
66
+ * scope and its counts. Graphite P1 (owner, 2026-09-30): the key that
67
+ * flips the scope moved to the key row, and a filter says how many of
68
+ * the scope match. */
69
+ export declare function scopeTitle(scope: PickScopeState | null, filter?: {
70
+ readonly matches: number;
71
+ readonly of: number;
72
+ } | null): string;
67
73
  /**
68
74
  * What the row SAYS about the state. The interrupted note is the
69
75
  * product's promise stated in the place the promise matters: the run
@@ -108,19 +114,6 @@ export declare function idColumn(cards: readonly SessionCardView[]): number;
108
114
  * the list.
109
115
  */
110
116
  export declare function sessionFilter(cards: readonly SessionCardView[], query: string): SessionCardView[];
111
- /**
112
- * ONE picker row. The selection is a FULL-ROW reverse bar — the R1.5
113
- * ⑧ ruling's shape, shared with the @ picker and the user chip: a
114
- * two-cell marker in an eighty-column row is a selection you have to
115
- * hunt for.
116
- *
117
- * The inner spans close with rvEnd inside the bar (never SGR 0, which
118
- * would punch a hole in it) — the same composition atRow uses.
119
- */
120
- export declare function sessionRow(card: SessionCardView, selected: boolean, W: number, now: number, idCol: number, here?: string | null): string;
121
- /** The counter row — the SELECTION's 1-based place in the whole
122
- * filtered list, which the visible window cannot tell the user. */
123
- export declare function sessionCounterRow(selected: number, total: number, W: number): string;
124
117
  /** The picker's bound state — the editor owns it, the compositor reads
125
118
  * it (the @ picker's contract, one surface over). */
126
119
  export interface SessionPickState {
@@ -129,16 +122,38 @@ export interface SessionPickState {
129
122
  readonly selected: number;
130
123
  /** 0.40.0: null when the picker was opened without a workspace. */
131
124
  readonly scope?: PickScopeState | null;
125
+ /** Graphite P1: the filter as typed — the title lights the letters it
126
+ * matched, and the band says how many match. Absent = no filter. */
127
+ readonly query?: string;
132
128
  }
129
+ /** How many sessions the band shows: eight on a terminal 30 rows or
130
+ * taller, five below (owner, 2026-09-30). */
131
+ export declare function resumeVisible(height: number): number;
132
+ /** The window over the matches, with a scroll-off of one: while more lies
133
+ * past an edge, the cursor stays a row inside it, so the edge row that
134
+ * carries a more-mark is never the selected one. Stateless, like
135
+ * bandWindow: the same (total, selected) always draws the same window. */
136
+ export declare function resumeWindow(total: number, selected: number, visible: number): {
137
+ first: number;
138
+ count: number;
139
+ };
140
+ /** The row's state word — empty for the quiet default, a finished session. */
141
+ export declare function sessionStateWord(card: SessionCardView): string;
142
+ /** When the session started, read from its id — the id IS the UTC stamp
143
+ * it was made at (apps/cli session-id.ts) — in local time. Null for an
144
+ * id that is not one. */
145
+ export declare function sessionStarted(id: string): string | null;
146
+ /** What the empty input says while the picker is up: typing there filters. */
147
+ export declare const RESUME_FILTER_HINT = "filter by title or id";
133
148
  /**
134
- * The whole band: the `sessions` header (R1.5 ⑦(b) — a band names
135
- * itself or it reads as more scrollback), at most AT_VISIBLE windowed
136
- * rows, then the counter. Returned as plain strings for the menu-rows
137
- * channel, which already accounts them in chromeRows — the picker needs
138
- * no geometry of its own, which is the entire reason it rides that
139
- * channel.
149
+ * The whole band: the named hairline, the windowed table with the
150
+ * selected row opened, and the key row. Returned as plain strings for the
151
+ * menu-rows channel, which already accounts them in chromeRows — the
152
+ * picker needs no geometry of its own, which is the entire reason it
153
+ * rides that channel. `height` is the terminal's: it sets how many
154
+ * sessions show (resumeVisible).
140
155
  */
141
- export declare function sessionPickerRows(state: SessionPickState, W: number, now: number): string[];
156
+ export declare function sessionPickerRows(state: SessionPickState, W: number, now: number, height?: number): string[];
142
157
  /**
143
158
  * Slice ③ — the `kiso sessions` TTY row: the same projection, printed
144
159
  * rather than picked. No selection bar (nothing is selected on a