@vincemakes/kiso-tui 0.52.0 → 0.54.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.
@@ -55,7 +55,7 @@ import { MOUSE_OFF } from "./editor.js";
55
55
  import { pickWindowOf } from "./approval-panel.js";
56
56
  import { atPanelRows, bandHeader, bandKeyRow, bandVisible, bandWindow, moreMark } from "./at-picker.js";
57
57
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
58
- import { RESUME_FILTER_HINT, sessionPickerRows } from "./session-picker.js";
58
+ import { FORK_FILTER_HINT, RESUME_FILTER_HINT, sessionPickerRows } from "./session-picker.js";
59
59
  import { CAP_PREVIEW, thinkingRow, Container, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, cutLine, pendingQueueRows, selectionBar, statusLine, visibleWidth, breathFrame, widthCut, } from "./components.js";
60
60
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./lines.js";
61
61
  import { oneRow } from "@vincemakes/kiso-tui-cells/render";
@@ -2657,7 +2657,7 @@ export class Body {
2657
2657
  // in the row's pad, so the cursor and the columns do not move.
2658
2658
  // Graphite P3: the same exception for a pick that filters (/model) —
2659
2659
  // its input is the filter too, and its hint is the caller's words.
2660
- const hint = panel === null ? (this.#pickState?.() != null ? RESUME_FILTER_HINT : null) : panelHintOf(panel);
2660
+ const hint = panel === null ? (this.#pickState?.() != null ? (this.#pickState?.()?.kind === "fork" ? FORK_FILTER_HINT : RESUME_FILTER_HINT) : null) : panelHintOf(panel);
2661
2661
  if (hint !== null && rows.length === 1 && rows[0] === "") {
2662
2662
  const pad = / +$/.exec(out[0]);
2663
2663
  const hintW = visibleWidth(hint);
package/dist/editor.d.ts CHANGED
@@ -132,6 +132,7 @@ export declare class Editor {
132
132
  * pop walks them (each pop leaves the queue, cancelling the turn);
133
133
  * esc ends the walk after one more pop. */
134
134
  bindQueue(state: () => readonly string[], pop: () => string | null): void;
135
+ setGhost(text: string | null): void;
135
136
  /** ADR-0057: put text back into the buffer — the steers a stop handed
136
137
  * back before they landed. It goes AHEAD of a draft already there, on
137
138
  * its own line, and the draft stays recoverable (undo). */
@@ -201,7 +202,7 @@ export declare class Editor {
201
202
  nameCol: number;
202
203
  } | null;
203
204
  /** Open the picker on a bound card source (PickInput, S5). */
204
- beginPick(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void, here?: string): void;
205
+ beginPick(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void, here?: string, kind?: "resume" | "fork"): void;
205
206
  pickState(): SessionPickState | null;
206
207
  /** One-shot question mode: the NEXT submit answers, not a turn. */
207
208
  question(_query: string, cb: (answer: string) => void): void;
package/dist/editor.js CHANGED
@@ -123,6 +123,8 @@ export const MENU_ITEMS = [
123
123
  // the /resume+/clear mini-spec: the session-navigation pair
124
124
  { name: "/clear", desc: "start a fresh conversation (the old session stays resumable)" },
125
125
  { name: "/resume", desc: "switch to another session; /resume <id> goes directly" },
126
+ // owner 2026-11-03: the web app's fork, one rule — after a reply, then switch
127
+ { name: "/fork", desc: "a new session from after one of this session's replies, then switch to it" },
126
128
  // 0.39.2: `/reload` has been dispatchable since §2.5 and `/help` lists
127
129
  // it, and typing `/` never offered it — the one place a person who does
128
130
  // not know a command exists goes to find one. Caught with `/copy` by the
@@ -589,6 +591,16 @@ export class Editor {
589
591
  this.#queueState = state;
590
592
  this.#queuePop = pop;
591
593
  }
594
+ /** The follow-up (owner 2026-11-03): a line the person may send next, drawn dim in the EMPTY box; Tab takes it into
595
+ * the buffer as real text. Null clears it. Typing hides it (the box is no longer empty); it is never sent by itself. */
596
+ #ghost = null;
597
+ setGhost(text) {
598
+ if (this.#ghost === text)
599
+ return;
600
+ this.#ghost = text === "" ? null : text;
601
+ if (this.#chars.length === 0)
602
+ this.#onRender();
603
+ }
592
604
  /** ADR-0057: put text back into the buffer — the steers a stop handed
593
605
  * back before they landed. It goes AHEAD of a draft already there, on
594
606
  * its own line, and the draft stays recoverable (undo). */
@@ -834,6 +846,21 @@ export class Editor {
834
846
  // the window, the markers and the cursor all count them the same
835
847
  // way — N_MAX has always been about how tall the box may get, and
836
848
  // a folded line is as tall as a pasted one.
849
+ if (this.#chars.length === 0 && this.#ghost !== null) {
850
+ // the follow-up stands in the empty box, dim, cut to one row; the cursor waits at its start
851
+ const budget = this.#budget();
852
+ let shownGhost = "";
853
+ for (const ch of this.#ghost) {
854
+ if (widthOf([...`${shownGhost}${ch}`].map((x) => x.codePointAt(0))) > budget - 1) {
855
+ shownGhost += "\u2026";
856
+ break;
857
+ }
858
+ shownGhost += ch;
859
+ }
860
+ const p = palette();
861
+ const line = `${p.dim}${shownGhost}${p.reset}`;
862
+ return { line, cursor: 0, lines: [line], cursorRow: 0, cursorCol: 0 };
863
+ }
837
864
  const rows = this.#visualRows();
838
865
  const cursorLine = this.#cursorVisualRow(rows);
839
866
  const n = this.#visibleRows(rows.length);
@@ -1056,8 +1083,8 @@ export class Editor {
1056
1083
  }
1057
1084
  // ── TUI2-R2 ② — the session picker ───────────────────────────────
1058
1085
  /** Open the picker on a bound card source (PickInput, S5). */
1059
- beginPick(cards, onPick, here) {
1060
- this.#pickInput.begin(cards, onPick, here);
1086
+ beginPick(cards, onPick, here, kind) {
1087
+ this.#pickInput.begin(cards, onPick, here, kind);
1061
1088
  }
1062
1089
  pickState() {
1063
1090
  return this.#pickInput.state();
@@ -1659,6 +1686,11 @@ export class Editor {
1659
1686
  this.#atAccept();
1660
1687
  i += 1;
1661
1688
  }
1689
+ else if (c === "\t" && this.#chars.length === 0 && this.#ghost !== null) {
1690
+ // the follow-up: Tab on the empty box takes it as real text
1691
+ this.restore(this.#ghost);
1692
+ i += 1;
1693
+ }
1662
1694
  else if (c === "\x16") {
1663
1695
  // REL-0152-D15: ctrl+V asks for the clipboard. Inert with no
1664
1696
  // hook wired, and 0x16 must NEVER reach the buffer — an
@@ -28,7 +28,7 @@ export declare class PickInput {
28
28
  * (the buffer becomes the filter query) and `onPick` receives the
29
29
  * chosen id — or null when the human leaves without picking, which
30
30
  * is a first-class outcome and not an error. */
31
- begin(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void, here?: string): void;
31
+ begin(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void, here?: string, kind?: "resume" | "fork"): void;
32
32
  /** The picker's state, derived: the full card list (the id column
33
33
  * measures over ALL of them, so the columns never jump), the
34
34
  * filtered matches, and the selection CLAMPED at read time — the
@@ -8,6 +8,7 @@ export class PickInput {
8
8
  * whether the person flipped to ALL. */
9
9
  #here = undefined;
10
10
  #all = false;
11
+ #kind = "resume";
11
12
  constructor(host) {
12
13
  this.host = host;
13
14
  }
@@ -18,8 +19,9 @@ export class PickInput {
18
19
  * (the buffer becomes the filter query) and `onPick` receives the
19
20
  * chosen id — or null when the human leaves without picking, which
20
21
  * is a first-class outcome and not an error. */
21
- begin(cards, onPick, here) {
22
+ begin(cards, onPick, here, kind = "resume") {
22
23
  this.#cards = cards;
24
+ this.#kind = kind;
23
25
  this.#commit = onPick;
24
26
  this.#sel = 0;
25
27
  this.#here = here;
@@ -43,7 +45,7 @@ export class PickInput {
43
45
  const cards = scoped === null ? this.#cards() : scoped.cards;
44
46
  const query = this.host.line();
45
47
  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 };
48
+ return { cards, matches, selected: Math.max(0, Math.min(this.#sel, matches.length - 1)), scope: scoped?.scope ?? null, query, kind: this.#kind };
47
49
  }
48
50
  /** 0.40.0 — tab flips CURRENT ↔ ALL. The filter owns every printable
49
51
  * key (the buffer IS the query), so the toggle cannot be one. True when
@@ -39,6 +39,9 @@ export interface SessionCardView {
39
39
  /** 0.40.0: its project was inferred by the one-time migration, never
40
40
  * recorded — the row says so. */
41
41
  readonly inferred?: boolean;
42
+ /** /fork's card: the opened row says this instead of a session's facts
43
+ * (the line the reply answered). */
44
+ readonly detail?: string;
42
45
  }
43
46
  /** 0.40.0 — which sessions the picker shows. `here` is the running
44
47
  * workspace's realpath; `all` is the person's choice; `unknown` counts
@@ -125,6 +128,9 @@ export interface SessionPickState {
125
128
  /** Graphite P1: the filter as typed — the title lights the letters it
126
129
  * matched, and the band says how many match. Absent = no filter. */
127
130
  readonly query?: string;
131
+ /** /fork (owner 2026-11-03): the same band picks a REPLY to fork after —
132
+ * its name, its enter and its empty line say so. Absent = /resume. */
133
+ readonly kind?: "resume" | "fork";
128
134
  }
129
135
  /** How many sessions the band shows: eight on a terminal 30 rows or
130
136
  * taller, five below (owner, 2026-09-30). */
@@ -145,6 +151,7 @@ export declare function sessionStateWord(card: SessionCardView): string;
145
151
  export declare function sessionStarted(id: string): string | null;
146
152
  /** What the empty input says while the picker is up: typing there filters. */
147
153
  export declare const RESUME_FILTER_HINT = "filter by title or id";
154
+ export declare const FORK_FILTER_HINT = "filter replies";
148
155
  /**
149
156
  * The whole band: the named hairline, the windowed table with the
150
157
  * selected row opened, and the key row. Returned as plain strings for the
@@ -438,6 +438,10 @@ export function sessionStarted(id) {
438
438
  function openedRow(card, W, where) {
439
439
  const p = palette();
440
440
  const room = Math.max(0, W - 3);
441
+ if (card.detail !== undefined) {
442
+ const said = widthCut(escapeTerminal(card.detail), room);
443
+ return selectionBar(` ${p.dim}${said}${p.reset}`, visibleWidth(said) + 1, W);
444
+ }
441
445
  const note = sessionNote(card);
442
446
  const dash = note.indexOf(" — ");
443
447
  const head = widthCut(dash < 0 ? note : note.slice(0, dash), room);
@@ -476,11 +480,12 @@ function resumeKeyRow(state, W, table) {
476
480
  const scope = state.scope ?? null;
477
481
  const elsewhere = scope === null ? 0 : scope.total - scope.inHere;
478
482
  const tab = scope === null ? null : scope.all ? "tab this workspace" : elsewhere > 0 ? `tab ${elsewhere} more elsewhere` : null;
479
- const keys = ["↑↓ move", "⏎ resumes", tab, "esc"].filter((k) => k !== null);
483
+ const enter = state.kind === "fork" ? "⏎ forks" : "⏎ resumes";
484
+ const keys = ["↑↓ move", enter, tab, "esc"].filter((k) => k !== null);
480
485
  const edge = Math.min(W, Math.max(table, visibleWidth(keys.join(" · ")) + count.length + 5));
481
486
  const fits = (ks) => 2 + visibleWidth(ks.join(" · ")) + 2 + count.length + 1 <= edge;
482
487
  let kept = keys;
483
- for (const drop of [tab, "↑↓ move", "⏎ resumes", "esc"]) {
488
+ for (const drop of [tab, "↑↓ move", enter, "esc"]) {
484
489
  if (fits(kept))
485
490
  break;
486
491
  kept = kept.filter((k) => k !== drop);
@@ -491,6 +496,7 @@ function resumeKeyRow(state, W, table) {
491
496
  }
492
497
  /** What the empty input says while the picker is up: typing there filters. */
493
498
  export const RESUME_FILTER_HINT = "filter by title or id";
499
+ export const FORK_FILTER_HINT = "filter replies";
494
500
  /**
495
501
  * The whole band: the named hairline, the windowed table with the
496
502
  * selected row opened, and the key row. Returned as plain strings for the
@@ -503,13 +509,15 @@ export function sessionPickerRows(state, W, now, height = 24) {
503
509
  const p = palette();
504
510
  const scope = state.scope ?? null;
505
511
  const query = state.query ?? "";
506
- const rows = [bandHeader(scopeTitle(scope, query === "" ? null : { matches: state.matches.length, of: state.cards.length }), W)];
512
+ const filter = query === "" ? null : { matches: state.matches.length, of: state.cards.length };
513
+ const title = state.kind === "fork" ? (filter === null ? "fork after a reply" : `fork after a reply · ${filter.matches} of ${filter.of} match`) : scopeTitle(scope, filter);
514
+ const rows = [bandHeader(title, W)];
507
515
  // the workspace column shows only when every workspace is listed
508
516
  const where = scope !== null && scope.all ? scope.here : null;
509
517
  const cols = resumeColumns(state.cards, W, now, where);
510
518
  if (state.matches.length === 0) {
511
519
  // an empty view says why, rather than an empty band
512
- const empty = query !== "" ? `nothing matches “${escapeTerminal(query)}”` : scope !== null && !scope.all && scope.inHere === 0 ? "no session from this workspace yet" : "nothing to resume yet";
520
+ const empty = query !== "" ? `nothing matches “${escapeTerminal(query)}”` : state.kind === "fork" ? "no finished reply to fork after yet" : scope !== null && !scope.all && scope.inHere === 0 ? "no session from this workspace yet" : "nothing to resume yet";
513
521
  rows.push(`${p.dim}${widthCut(` ${empty}`, W)}${p.reset}`);
514
522
  rows.push(resumeKeyRow(state, W, cols.table));
515
523
  return rows;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "kiso tui \u2014 the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.52.0"
38
+ "@vincemakes/kiso-tui-cells": "0.54.0"
39
39
  }
40
40
  }