@sigrun/tui 0.1.0 → 0.2.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +3 -3
  3. package/dist/actions.d.ts +0 -78
  4. package/dist/actions.js +0 -12
  5. package/dist/api.d.ts +0 -147
  6. package/dist/api.js +0 -185
  7. package/dist/clipboard.d.ts +0 -20
  8. package/dist/clipboard.js +0 -19
  9. package/dist/editor-launch.d.ts +0 -34
  10. package/dist/editor-launch.js +0 -70
  11. package/dist/keymaps/emacs.d.ts +0 -18
  12. package/dist/keymaps/emacs.js +0 -133
  13. package/dist/keymaps/standard.d.ts +0 -17
  14. package/dist/keymaps/standard.js +0 -131
  15. package/dist/keymaps/types.d.ts +0 -43
  16. package/dist/keymaps/types.js +0 -28
  17. package/dist/keymaps/vim.d.ts +0 -48
  18. package/dist/keymaps/vim.js +0 -254
  19. package/dist/launch.d.ts +0 -149
  20. package/dist/launch.js +0 -433
  21. package/dist/sse.d.ts +0 -101
  22. package/dist/sse.js +0 -374
  23. package/dist/theme.d.ts +0 -51
  24. package/dist/theme.js +0 -105
  25. package/dist/ui/ambient.d.ts +0 -81
  26. package/dist/ui/ambient.js +0 -301
  27. package/dist/ui/app.d.ts +0 -209
  28. package/dist/ui/app.js +0 -1241
  29. package/dist/ui/composer.d.ts +0 -66
  30. package/dist/ui/composer.js +0 -125
  31. package/dist/ui/conversation-pane.d.ts +0 -123
  32. package/dist/ui/conversation-pane.js +0 -463
  33. package/dist/ui/help.d.ts +0 -15
  34. package/dist/ui/help.js +0 -80
  35. package/dist/ui/interactions.d.ts +0 -90
  36. package/dist/ui/interactions.js +0 -335
  37. package/dist/ui/picker.d.ts +0 -26
  38. package/dist/ui/picker.js +0 -130
  39. package/dist/ui/sidebar.d.ts +0 -76
  40. package/dist/ui/sidebar.js +0 -205
  41. package/dist/ui/status-bar.d.ts +0 -77
  42. package/dist/ui/status-bar.js +0 -146
@@ -1,205 +0,0 @@
1
- /**
2
- * The sidebar (Task 6; restyled in C2, UI refresh): the conversation
3
- * TREE — rows come from B1's `buildConversationTree` (fork children
4
- * indent two spaces + `↳ `), names are single-line via B1's
5
- * `displayName` + pi-tui's `truncateToWidth`, ages right-align via B1's
6
- * `formatAge`, and the active row highlights accent-fg + bold. Archived
7
- * conversations stay hidden (the tree's `includeArchived: false`; the
8
- * server has no delete, archive is the hide). `+ New conversation` is
9
- * always the last row.
10
- *
11
- * The listing itself carries every field the rows need (`name`,
12
- * `archived`, `pinned`, `parent`, `lastActivityAt` ride
13
- * `ConversationSummary` — A1), so {@link refresh} makes ONE request: no
14
- * per-row meta fetch. The app re-refreshes on a 10 s poll (an unref'd
15
- * `setInterval` the launcher clears on dispose).
16
- *
17
- * Keyboard-first (mouse is additive): `cursor` / `moveCursor` /
18
- * `confirm()` are the C3 action layer's seam; a click on a row selects
19
- * it (`MouseRegion` per row). Selection reports to {@link onSelect}: a
20
- * conversation id, or `"new"` for the `+ New conversation` row
21
- * (creation is the app's job — the sidebar never calls
22
- * createConversation itself).
23
- */
24
- import { MouseRegion, Text, truncateToWidth, visibleWidth, VStack, } from "@earendil-works/pi-tui";
25
- import { buildConversationTree, displayName, formatAge, } from "@sigrun/shared/conversations";
26
- import { plainTuiTheme } from "../theme.js";
27
- /** The `+ New conversation` row's id (never a conversation id). */
28
- const NEW_ROW_ID = -1;
29
- export class Sidebar {
30
- #client;
31
- #theme;
32
- #now;
33
- #stack = new VStack();
34
- #header;
35
- #rowComponents = [];
36
- /** Conversation rows only (the action row is {@link #newRow}). */
37
- #rows = [];
38
- /** The trailing `+ New conversation` action row (always present). */
39
- #newRow = {
40
- id: NEW_ROW_ID,
41
- name: "+ New conversation",
42
- depth: 0,
43
- age: "",
44
- active: false,
45
- };
46
- #activeId;
47
- #cursor = 0;
48
- /** Selection callback: the conversation id, or `"new"` for `+ new`. */
49
- onSelect;
50
- constructor(client, options) {
51
- this.#client = client;
52
- this.#theme = options?.theme ?? plainTuiTheme();
53
- this.#now = options?.now ?? (() => new Date());
54
- this.#mount();
55
- }
56
- /** The conversation rows, in display order (the app's boot default
57
- * reads `rows[0]`); the trailing `+ New conversation` action row is
58
- * NOT part of this list — it renders last via {@link #newRow}. */
59
- get rows() {
60
- return this.#rows;
61
- }
62
- /** The display list: the conversation rows plus the action row. */
63
- get #display() {
64
- return [...this.#rows, this.#newRow];
65
- }
66
- /** The cursor's row index (0-based over {@link rows}). */
67
- get cursor() {
68
- return this.#cursor;
69
- }
70
- /**
71
- * Re-fetch the listing and rebuild the rows from the tree (summary
72
- * fields only — no per-row meta fetch). The cursor follows its
73
- * conversation when it still exists.
74
- */
75
- async refresh() {
76
- const list = await this.#client.listConversations();
77
- const previousId = this.#display[this.#cursor]?.id;
78
- const tree = buildConversationTree(list.conversations, {
79
- includeArchived: false,
80
- });
81
- const rows = [];
82
- const walk = (nodes, depth) => {
83
- for (const node of nodes) {
84
- rows.push(this.#row(node.row, depth));
85
- walk(node.children, depth + 1);
86
- }
87
- };
88
- walk(tree, 0);
89
- this.#rows = rows;
90
- if (previousId !== undefined) {
91
- const index = rows.findIndex((row) => row.id === previousId);
92
- if (index >= 0)
93
- this.#cursor = index;
94
- }
95
- this.#clampCursor();
96
- this.#mount();
97
- }
98
- /** Mark the active conversation (accent fg + bold); pass `undefined` to clear. */
99
- setActive(id) {
100
- if (id === this.#activeId)
101
- return;
102
- this.#activeId = id;
103
- this.#rows = this.#rows.map((row) => row.id === NEW_ROW_ID ? row : { ...row, active: row.id === id });
104
- this.#mount();
105
- }
106
- /** Move the cursor by `delta` rows, clamped to the list. */
107
- moveCursor(delta) {
108
- this.#cursor += delta;
109
- this.#clampCursor();
110
- this.#stack.invalidate();
111
- }
112
- /** Select the cursor's row (Enter — the action layer's seam). */
113
- confirm() {
114
- const row = this.#display[this.#cursor];
115
- if (row === undefined)
116
- return;
117
- this.onSelect?.(row.id === NEW_ROW_ID ? "new" : row.id);
118
- }
119
- /** The keyboard seam: arrows move, Enter confirms (pre-C3 minimal). */
120
- handleInput(data) {
121
- if (data.endsWith("[A")) {
122
- this.moveCursor(-1);
123
- }
124
- else if (data.endsWith("[B")) {
125
- this.moveCursor(1);
126
- }
127
- else if (data === "\r") {
128
- this.confirm();
129
- }
130
- }
131
- #clampCursor() {
132
- // The display list always has the action row, so it is never empty.
133
- this.#cursor = Math.max(0, Math.min(this.#cursor, this.#display.length - 1));
134
- }
135
- #row(summary, depth) {
136
- return {
137
- id: summary.id,
138
- name: displayName(summary),
139
- depth,
140
- age: formatAge(summary.lastActivityAt, this.#now()),
141
- active: summary.id === this.#activeId,
142
- };
143
- }
144
- /** One-line placeholder (pi-tui's `Text` drops whitespace-only text). */
145
- #rowLine() {
146
- return {
147
- render: () => [""],
148
- invalidate: () => { },
149
- };
150
- }
151
- /** Rebuild the row components (header + one MouseRegion per row). */
152
- #mount() {
153
- this.#stack.clear();
154
- this.#rowComponents = [];
155
- this.#header = new Text(this.#theme.dim("CONVERSATIONS"), 1, 0);
156
- this.#stack.addChild(this.#header);
157
- for (const [index, row] of this.#display.entries()) {
158
- const line = this.#rowLine();
159
- const component = new MouseRegion(line, (event) => {
160
- if (event.type === "click" && event.button === "left") {
161
- this.#cursor = index;
162
- this.confirm();
163
- return { handled: true };
164
- }
165
- return undefined;
166
- });
167
- this.#rowComponents.push(component);
168
- this.#stack.addChild(component);
169
- }
170
- this.#stack.invalidate();
171
- }
172
- /** One row's styled text at the render width. */
173
- #rowText(row, index, width) {
174
- const indent = " ".repeat(row.depth);
175
- const marker = row.depth > 0 ? "↳ " : "";
176
- const cursor = index === this.#cursor ? "› " : " ";
177
- const age = row.age;
178
- const nameWidth = Math.max(1, width - visibleWidth(cursor + indent + marker) - (age === "" ? 0 : age.length + 1));
179
- const name = truncateToWidth(`${indent}${marker}${row.name}`, nameWidth);
180
- const gap = age === "" ? "" : " ".repeat(Math.max(1, width - visibleWidth(cursor + name) - age.length));
181
- let text = `${cursor}${name}${gap}${age}`;
182
- text = truncateToWidth(text, width, "…", true);
183
- if (row.active) {
184
- return this.#theme.bold(this.#theme.fg("accent")(text));
185
- }
186
- if (row.id === NEW_ROW_ID) {
187
- return this.#theme.fg("accent")(text);
188
- }
189
- return text;
190
- }
191
- render(width) {
192
- // The row components render empty; the rows are drawn here so the
193
- // width is known (right-aligned ages need it).
194
- const lines = this.#stack.render(width);
195
- // Header is line 0; one line per row follows (rows never wrap —
196
- // truncateToWidth pins them to one line).
197
- for (const [index, row] of this.#display.entries()) {
198
- lines[index + 1] = this.#rowText(row, index, width);
199
- }
200
- return lines;
201
- }
202
- invalidate() {
203
- this.#stack.invalidate();
204
- }
205
- }
@@ -1,77 +0,0 @@
1
- /**
2
- * The status bar (Task C1, UI refresh): exactly one rendered line —
3
- * left: the mode chip on the accent background + the conversation name;
4
- * right: `model · thinking · ctx N%`, then the hint keys. A transient
5
- * `message` (4 s, timer-based) replaces the hints — stream notices ride
6
- * here. Narrow-width policy: drop hints first, then thinking; model and
7
- * ctx are the two facts that never drop (what model, how full).
8
- *
9
- * A pi-tui `Component` (`render(width): string[]`, one line) — mounting
10
- * is the launcher's layout job (basis 1), not this component's.
11
- *
12
- * Mouse (Task C3, additive to ctrl+p/ctrl+t): a left click on the right
13
- * cluster's MODEL segment fires {@link StatusBar.onModelClick} (the model
14
- * picker), one on the THINKING segment fires
15
- * {@link StatusBar.onThinkingClick} (the cycle). The hit ranges mirror
16
- * render()'s full/compact fallback, so a narrowed bar never mis-hits a
17
- * dropped segment.
18
- */
19
- import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
20
- import type { TuiTheme } from "../theme.js";
21
- export interface StatusBarState {
22
- /** The mode chip text (e.g. "attach"); absent → no chip. */
23
- readonly mode?: string;
24
- readonly conversation: string;
25
- readonly model?: string;
26
- /** The thinking indicator label (e.g. "thinking"); absent → dropped. */
27
- readonly thinking?: string;
28
- /** 0-100; absent → no ctx segment. */
29
- readonly contextPct?: number;
30
- readonly hints: readonly string[];
31
- /** Transient notice; replaces the hints for 4 s. */
32
- readonly message?: string;
33
- /**
34
- * The vim `:` command line (C4): rendered after `message` and NOT
35
- * subject to the flash TTL — it persists for the whole command mode
36
- * (a slow typist must never lose the line mid-edit).
37
- */
38
- readonly commandLine?: string;
39
- }
40
- export interface StatusBarOptions {
41
- readonly theme: TuiTheme;
42
- /** The message lifetime (ms); defaults to 4000. Tests may pin it. */
43
- readonly messageMs?: number;
44
- }
45
- export declare class StatusBar implements Component {
46
- /** Click sink for the right cluster's model segment (the picker). */
47
- onModelClick: (() => void) | undefined;
48
- /** Click sink for the right cluster's thinking segment (the cycle). */
49
- onThinkingClick: (() => void) | undefined;
50
- private readonly theme;
51
- private readonly messageMs;
52
- private state;
53
- private messageTimer;
54
- constructor(options: StatusBarOptions, init?: Partial<StatusBarState>);
55
- /** Replace the state wholesale (the whole line is derived each render). */
56
- set(state: StatusBarState): void;
57
- /** The current state (the app's flash/config snapshot reads it). */
58
- getState(): StatusBarState;
59
- private armMessageExpiry;
60
- /**
61
- * The mouse surface: dispatch a left click on the model / thinking
62
- * segment; everything else (the left side, ctx, hints, other buttons)
63
- * passes through.
64
- */
65
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
66
- /**
67
- * The model/thinking segments' cell ranges under render()'s layout:
68
- * the full right cluster, or the compact fallback (thinking dropped).
69
- * The truncated-compact case keeps the compact offsets — cells past
70
- * the width cannot be clicked anyway.
71
- */
72
- private segmentRanges;
73
- /** No cached rendering state — the line is derived each render. */
74
- invalidate(): void;
75
- render(width: number): string[];
76
- private renderLeft;
77
- }
@@ -1,146 +0,0 @@
1
- /**
2
- * The status bar (Task C1, UI refresh): exactly one rendered line —
3
- * left: the mode chip on the accent background + the conversation name;
4
- * right: `model · thinking · ctx N%`, then the hint keys. A transient
5
- * `message` (4 s, timer-based) replaces the hints — stream notices ride
6
- * here. Narrow-width policy: drop hints first, then thinking; model and
7
- * ctx are the two facts that never drop (what model, how full).
8
- *
9
- * A pi-tui `Component` (`render(width): string[]`, one line) — mounting
10
- * is the launcher's layout job (basis 1), not this component's.
11
- *
12
- * Mouse (Task C3, additive to ctrl+p/ctrl+t): a left click on the right
13
- * cluster's MODEL segment fires {@link StatusBar.onModelClick} (the model
14
- * picker), one on the THINKING segment fires
15
- * {@link StatusBar.onThinkingClick} (the cycle). The hit ranges mirror
16
- * render()'s full/compact fallback, so a narrowed bar never mis-hits a
17
- * dropped segment.
18
- */
19
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
- const MESSAGE_MS = 4000;
21
- const SEPARATOR = " · ";
22
- /** Build the right cluster: model · thinking · ctx N%, then hints. */
23
- function rightCluster(state) {
24
- const parts = [];
25
- if (state.model !== undefined)
26
- parts.push(state.model);
27
- if (state.thinking !== undefined)
28
- parts.push(state.thinking);
29
- if (state.contextPct !== undefined)
30
- parts.push(`ctx ${Math.round(state.contextPct)}%`);
31
- const head = parts.join(SEPARATOR);
32
- const tail = state.commandLine ?? state.message ?? state.hints.join(" ");
33
- return tail === "" ? head : `${head}${SEPARATOR}${tail}`;
34
- }
35
- /** The narrow-width right cluster: thinking (and hints) dropped. */
36
- function rightClusterCompact(state) {
37
- const parts = [];
38
- if (state.model !== undefined)
39
- parts.push(state.model);
40
- if (state.contextPct !== undefined)
41
- parts.push(`ctx ${Math.round(state.contextPct)}%`);
42
- if (state.commandLine !== undefined)
43
- parts.push(state.commandLine);
44
- return parts.join(SEPARATOR);
45
- }
46
- export class StatusBar {
47
- /** Click sink for the right cluster's model segment (the picker). */
48
- onModelClick;
49
- /** Click sink for the right cluster's thinking segment (the cycle). */
50
- onThinkingClick;
51
- theme;
52
- messageMs;
53
- state;
54
- messageTimer;
55
- constructor(options, init) {
56
- this.theme = options.theme;
57
- this.messageMs = options.messageMs ?? MESSAGE_MS;
58
- this.state = { conversation: "", hints: [], ...init };
59
- }
60
- /** Replace the state wholesale (the whole line is derived each render). */
61
- set(state) {
62
- this.state = state;
63
- if (state.message !== undefined) {
64
- this.armMessageExpiry();
65
- }
66
- }
67
- /** The current state (the app's flash/config snapshot reads it). */
68
- getState() {
69
- return this.state;
70
- }
71
- armMessageExpiry() {
72
- if (this.messageTimer !== undefined)
73
- clearTimeout(this.messageTimer);
74
- this.messageTimer = setTimeout(() => {
75
- this.messageTimer = undefined;
76
- const { message: _message, ...rest } = this.state;
77
- this.state = rest;
78
- }, this.messageMs);
79
- }
80
- /**
81
- * The mouse surface: dispatch a left click on the model / thinking
82
- * segment; everything else (the left side, ctx, hints, other buttons)
83
- * passes through.
84
- */
85
- handleMouse(event) {
86
- if (event.type !== "click" || event.button !== "left")
87
- return undefined;
88
- for (const range of this.segmentRanges(event.width)) {
89
- if (event.x < range.start || event.x >= range.end)
90
- continue;
91
- const sink = range.kind === "model" ? this.onModelClick : this.onThinkingClick;
92
- if (sink === undefined)
93
- return undefined;
94
- sink();
95
- return { handled: true };
96
- }
97
- return undefined;
98
- }
99
- /**
100
- * The model/thinking segments' cell ranges under render()'s layout:
101
- * the full right cluster, or the compact fallback (thinking dropped).
102
- * The truncated-compact case keeps the compact offsets — cells past
103
- * the width cannot be clicked anyway.
104
- */
105
- segmentRanges(width) {
106
- const leftWidth = visibleWidth(this.renderLeft());
107
- const useFull = leftWidth + 2 + visibleWidth(rightCluster(this.state)) <= width;
108
- const parts = [];
109
- if (this.state.model !== undefined)
110
- parts.push({ kind: "model", text: this.state.model });
111
- if (useFull && this.state.thinking !== undefined)
112
- parts.push({ kind: "thinking", text: this.state.thinking });
113
- if (this.state.contextPct !== undefined)
114
- parts.push({ kind: "ctx", text: `ctx ${Math.round(this.state.contextPct)}%` });
115
- const ranges = [];
116
- let cursor = leftWidth + 2;
117
- for (const part of parts) {
118
- const end = cursor + visibleWidth(part.text);
119
- if (part.kind !== "ctx")
120
- ranges.push({ kind: part.kind, start: cursor, end });
121
- cursor = end + visibleWidth(SEPARATOR);
122
- }
123
- return ranges;
124
- }
125
- /** No cached rendering state — the line is derived each render. */
126
- invalidate() { }
127
- render(width) {
128
- const left = this.renderLeft();
129
- const full = `${left} ${this.theme.fg("muted")(rightCluster(this.state))}`;
130
- if (visibleWidth(full) <= width)
131
- return [full];
132
- const compact = `${left} ${this.theme.fg("muted")(rightClusterCompact(this.state))}`;
133
- if (visibleWidth(compact) <= width)
134
- return [compact];
135
- // Still too narrow: truncate the compact line to the width.
136
- return [truncateToWidth(compact, width, "…", true)];
137
- }
138
- renderLeft() {
139
- const conversation = this.theme.fg("text")(this.state.conversation);
140
- if (this.state.mode === undefined || this.state.mode === "") {
141
- return conversation;
142
- }
143
- const chip = this.theme.bg("accent")(this.theme.bold(this.state.mode));
144
- return `${chip} ${conversation}`;
145
- }
146
- }