@sigrun/tui 0.1.1 → 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 (108) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +2 -2
  3. package/dist/actions.d.ts +0 -119
  4. package/dist/actions.js +0 -12
  5. package/dist/api.d.ts +0 -152
  6. package/dist/api.js +0 -189
  7. package/dist/blocks/braille.d.ts +0 -41
  8. package/dist/blocks/braille.js +0 -131
  9. package/dist/blocks/chart.d.ts +0 -20
  10. package/dist/blocks/chart.js +0 -397
  11. package/dist/blocks/collection/calendar.d.ts +0 -28
  12. package/dist/blocks/collection/calendar.js +0 -232
  13. package/dist/blocks/collection/card.d.ts +0 -36
  14. package/dist/blocks/collection/card.js +0 -128
  15. package/dist/blocks/collection/collection.d.ts +0 -9
  16. package/dist/blocks/collection/collection.js +0 -26
  17. package/dist/blocks/collection/grid.d.ts +0 -18
  18. package/dist/blocks/collection/grid.js +0 -72
  19. package/dist/blocks/collection/lanes.d.ts +0 -32
  20. package/dist/blocks/collection/lanes.js +0 -320
  21. package/dist/blocks/collection/list.d.ts +0 -30
  22. package/dist/blocks/collection/list.js +0 -273
  23. package/dist/blocks/collection/move.d.ts +0 -67
  24. package/dist/blocks/collection/move.js +0 -96
  25. package/dist/blocks/collection/shared.d.ts +0 -87
  26. package/dist/blocks/collection/shared.js +0 -177
  27. package/dist/blocks/collection/table.d.ts +0 -26
  28. package/dist/blocks/collection/table.js +0 -221
  29. package/dist/blocks/content.d.ts +0 -48
  30. package/dist/blocks/content.js +0 -253
  31. package/dist/blocks/context.d.ts +0 -96
  32. package/dist/blocks/context.js +0 -65
  33. package/dist/blocks/diff.d.ts +0 -21
  34. package/dist/blocks/diff.js +0 -25
  35. package/dist/blocks/ext-transport.d.ts +0 -70
  36. package/dist/blocks/ext-transport.js +0 -165
  37. package/dist/blocks/form/choice.d.ts +0 -54
  38. package/dist/blocks/form/choice.js +0 -175
  39. package/dist/blocks/form/fields.d.ts +0 -56
  40. package/dist/blocks/form/fields.js +0 -136
  41. package/dist/blocks/form/file.d.ts +0 -61
  42. package/dist/blocks/form/file.js +0 -121
  43. package/dist/blocks/form/form.d.ts +0 -53
  44. package/dist/blocks/form/form.js +0 -703
  45. package/dist/blocks/form/list.d.ts +0 -37
  46. package/dist/blocks/form/list.js +0 -70
  47. package/dist/blocks/form/values.d.ts +0 -20
  48. package/dist/blocks/form/values.js +0 -104
  49. package/dist/blocks/icons.d.ts +0 -14
  50. package/dist/blocks/icons.js +0 -74
  51. package/dist/blocks/image.d.ts +0 -39
  52. package/dist/blocks/image.js +0 -82
  53. package/dist/blocks/layout.d.ts +0 -31
  54. package/dist/blocks/layout.js +0 -155
  55. package/dist/blocks/log.d.ts +0 -24
  56. package/dist/blocks/log.js +0 -111
  57. package/dist/blocks/meter.d.ts +0 -16
  58. package/dist/blocks/meter.js +0 -50
  59. package/dist/blocks/page.d.ts +0 -54
  60. package/dist/blocks/page.js +0 -355
  61. package/dist/blocks/render.d.ts +0 -31
  62. package/dist/blocks/render.js +0 -145
  63. package/dist/blocks/surface-view.d.ts +0 -33
  64. package/dist/blocks/surface-view.js +0 -86
  65. package/dist/blocks/surface.d.ts +0 -75
  66. package/dist/blocks/surface.js +0 -468
  67. package/dist/blocks/tones.d.ts +0 -23
  68. package/dist/blocks/tones.js +0 -63
  69. package/dist/blocks/treeview.d.ts +0 -22
  70. package/dist/blocks/treeview.js +0 -121
  71. package/dist/blocks/width.d.ts +0 -32
  72. package/dist/blocks/width.js +0 -70
  73. package/dist/clipboard.d.ts +0 -20
  74. package/dist/clipboard.js +0 -19
  75. package/dist/editor-launch.d.ts +0 -34
  76. package/dist/editor-launch.js +0 -70
  77. package/dist/keymaps/emacs.d.ts +0 -18
  78. package/dist/keymaps/emacs.js +0 -172
  79. package/dist/keymaps/standard.d.ts +0 -17
  80. package/dist/keymaps/standard.js +0 -175
  81. package/dist/keymaps/types.d.ts +0 -43
  82. package/dist/keymaps/types.js +0 -28
  83. package/dist/keymaps/vim.d.ts +0 -48
  84. package/dist/keymaps/vim.js +0 -291
  85. package/dist/launch.d.ts +0 -149
  86. package/dist/launch.js +0 -446
  87. package/dist/sse.d.ts +0 -109
  88. package/dist/sse.js +0 -401
  89. package/dist/theme.d.ts +0 -51
  90. package/dist/theme.js +0 -119
  91. package/dist/ui/ambient.d.ts +0 -81
  92. package/dist/ui/ambient.js +0 -301
  93. package/dist/ui/app.d.ts +0 -245
  94. package/dist/ui/app.js +0 -1325
  95. package/dist/ui/composer.d.ts +0 -66
  96. package/dist/ui/composer.js +0 -125
  97. package/dist/ui/conversation-pane.d.ts +0 -123
  98. package/dist/ui/conversation-pane.js +0 -463
  99. package/dist/ui/help.d.ts +0 -15
  100. package/dist/ui/help.js +0 -80
  101. package/dist/ui/interactions.d.ts +0 -90
  102. package/dist/ui/interactions.js +0 -330
  103. package/dist/ui/picker.d.ts +0 -26
  104. package/dist/ui/picker.js +0 -130
  105. package/dist/ui/sidebar.d.ts +0 -99
  106. package/dist/ui/sidebar.js +0 -269
  107. package/dist/ui/status-bar.d.ts +0 -77
  108. package/dist/ui/status-bar.js +0 -146
@@ -1,269 +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 extension VIEW rows (U3 Task 5): one per declared view surface,
39
- * rendered under the VIEWS group header. */
40
- #views = [];
41
- /** The trailing `+ New conversation` action row (always present). */
42
- #newRow = {
43
- id: NEW_ROW_ID,
44
- name: "+ New conversation",
45
- depth: 0,
46
- age: "",
47
- active: false,
48
- };
49
- #activeId;
50
- #cursor = 0;
51
- /** Selection callback: the conversation id, or `"new"` for `+ new`. */
52
- onSelect;
53
- constructor(client, options) {
54
- this.#client = client;
55
- this.#theme = options?.theme ?? plainTuiTheme();
56
- this.#now = options?.now ?? (() => new Date());
57
- this.#mount();
58
- }
59
- /** The conversation rows, in display order (the app's boot default
60
- * reads `rows[0]`); the trailing `+ New conversation` action row is
61
- * NOT part of this list — it renders last via {@link #newRow}. */
62
- get rows() {
63
- return this.#rows;
64
- }
65
- /** The display list: the conversation rows plus the action row. */
66
- get #display() {
67
- return [...this.#rows, this.#newRow];
68
- }
69
- /** The cursor's row index (0-based over {@link rows}). */
70
- get cursor() {
71
- return this.#cursor;
72
- }
73
- /**
74
- * Re-fetch the listing and rebuild the rows from the tree (summary
75
- * fields only — no per-row meta fetch). The cursor follows its
76
- * conversation when it still exists.
77
- */
78
- async refresh() {
79
- const list = await this.#client.listConversations();
80
- const previousId = this.#display[this.#cursor]?.id;
81
- const tree = buildConversationTree(list.conversations, {
82
- includeArchived: false,
83
- });
84
- const rows = [];
85
- const walk = (nodes, depth) => {
86
- for (const node of nodes) {
87
- rows.push(this.#row(node.row, depth));
88
- walk(node.children, depth + 1);
89
- }
90
- };
91
- walk(tree, 0);
92
- this.#rows = rows;
93
- if (previousId !== undefined) {
94
- const index = rows.findIndex((row) => row.id === previousId);
95
- if (index >= 0)
96
- this.#cursor = index;
97
- }
98
- this.#clampCursor();
99
- this.#mount();
100
- }
101
- /**
102
- * The extension views (U3 Task 5): refetch `GET /v1/extensions` and
103
- * rebuild the VIEWS group (one row per declared view surface, in
104
- * declaration order, status "ok" only). Panels live in the side pane,
105
- * not here. Failures keep the last good list (a flaky server must
106
- * never blank the sidebar).
107
- */
108
- async refreshViews() {
109
- try {
110
- const list = await this.#client.listExtensions();
111
- const views = [];
112
- for (const extension of list.extensions) {
113
- if (extension.status !== "ok")
114
- continue;
115
- for (const surface of extension.surfaces ?? []) {
116
- if (surface.placement !== "view")
117
- continue;
118
- views.push({ slug: extension.name, id: surface.id, title: surface.title });
119
- }
120
- }
121
- this.#views = views;
122
- this.#mount();
123
- }
124
- catch {
125
- // fail quiet — the last good list stands
126
- }
127
- }
128
- /** The extension view rows (the app's openExtView reads them). */
129
- get views() {
130
- return this.#views;
131
- }
132
- /** Select the Nth extension view (the action layer's seam). */
133
- confirmView(index) {
134
- const view = this.#views[index];
135
- if (view === undefined)
136
- return;
137
- this.onSelect?.({ view: { slug: view.slug, id: view.id } });
138
- }
139
- /** Mark the active conversation (accent fg + bold); pass `undefined` to clear. */
140
- setActive(id) {
141
- if (id === this.#activeId)
142
- return;
143
- this.#activeId = id;
144
- this.#rows = this.#rows.map((row) => row.id === NEW_ROW_ID ? row : { ...row, active: row.id === id });
145
- this.#mount();
146
- }
147
- /** Move the cursor by `delta` rows, clamped to the list. */
148
- moveCursor(delta) {
149
- this.#cursor += delta;
150
- this.#clampCursor();
151
- this.#stack.invalidate();
152
- }
153
- /** Select the cursor's row (Enter — the action layer's seam). */
154
- confirm() {
155
- const row = this.#display[this.#cursor];
156
- if (row === undefined)
157
- return;
158
- this.onSelect?.(row.id === NEW_ROW_ID ? "new" : row.id);
159
- }
160
- /** The keyboard seam: arrows move, Enter confirms (pre-C3 minimal). */
161
- handleInput(data) {
162
- if (data.endsWith("[A")) {
163
- this.moveCursor(-1);
164
- }
165
- else if (data.endsWith("[B")) {
166
- this.moveCursor(1);
167
- }
168
- else if (data === "\r") {
169
- this.confirm();
170
- }
171
- }
172
- #clampCursor() {
173
- // The display list always has the action row, so it is never empty.
174
- this.#cursor = Math.max(0, Math.min(this.#cursor, this.#display.length - 1));
175
- }
176
- #row(summary, depth) {
177
- return {
178
- id: summary.id,
179
- name: displayName(summary),
180
- depth,
181
- age: formatAge(summary.lastActivityAt, this.#now()),
182
- active: summary.id === this.#activeId,
183
- };
184
- }
185
- /** One-line placeholder (pi-tui's `Text` drops whitespace-only text). */
186
- #rowLine() {
187
- return {
188
- render: () => [""],
189
- invalidate: () => { },
190
- };
191
- }
192
- /** Rebuild the row components (header + one MouseRegion per row). */
193
- #mount() {
194
- this.#stack.clear();
195
- this.#rowComponents = [];
196
- this.#header = new Text(this.#theme.dim("CONVERSATIONS"), 1, 0);
197
- this.#stack.addChild(this.#header);
198
- for (const [index, row] of this.#display.entries()) {
199
- const line = this.#rowLine();
200
- const component = new MouseRegion(line, (event) => {
201
- if (event.type === "click" && event.button === "left") {
202
- this.#cursor = index;
203
- this.confirm();
204
- return { handled: true };
205
- }
206
- return undefined;
207
- });
208
- this.#rowComponents.push(component);
209
- this.#stack.addChild(component);
210
- }
211
- // The VIEWS group (U3 Task 5): one row per declared extension view.
212
- if (this.#views.length > 0) {
213
- this.#stack.addChild(new Text(this.#theme.dim("VIEWS"), 1, 0));
214
- for (const view of this.#views) {
215
- const line = this.#rowLine();
216
- const component = new MouseRegion(line, (event) => {
217
- if (event.type === "click" && event.button === "left") {
218
- this.onSelect?.({ view: { slug: view.slug, id: view.id } });
219
- return { handled: true };
220
- }
221
- return undefined;
222
- });
223
- this.#rowComponents.push(component);
224
- this.#stack.addChild(component);
225
- }
226
- }
227
- this.#stack.invalidate();
228
- }
229
- /** One row's styled text at the render width. */
230
- #rowText(row, index, width) {
231
- const indent = " ".repeat(row.depth);
232
- const marker = row.depth > 0 ? "↳ " : "";
233
- const cursor = index === this.#cursor ? "› " : " ";
234
- const age = row.age;
235
- const nameWidth = Math.max(1, width - visibleWidth(cursor + indent + marker) - (age === "" ? 0 : age.length + 1));
236
- const name = truncateToWidth(`${indent}${marker}${row.name}`, nameWidth);
237
- const gap = age === "" ? "" : " ".repeat(Math.max(1, width - visibleWidth(cursor + name) - age.length));
238
- let text = `${cursor}${name}${gap}${age}`;
239
- text = truncateToWidth(text, width, "…", true);
240
- if (row.active) {
241
- return this.#theme.bold(this.#theme.fg("accent")(text));
242
- }
243
- if (row.id === NEW_ROW_ID) {
244
- return this.#theme.fg("accent")(text);
245
- }
246
- return text;
247
- }
248
- render(width) {
249
- // The row components render empty; the rows are drawn here so the
250
- // width is known (right-aligned ages need it).
251
- const lines = this.#stack.render(width);
252
- // Header is line 0; one line per row follows (rows never wrap —
253
- // truncateToWidth pins them to one line).
254
- for (const [index, row] of this.#display.entries()) {
255
- lines[index + 1] = this.#rowText(row, index, width);
256
- }
257
- // The VIEWS group rows (after the conversations + the VIEWS header).
258
- if (this.#views.length > 0) {
259
- const base = this.#display.length + 2; // header + rows + VIEWS header
260
- for (const [index, view] of this.#views.entries()) {
261
- lines[base + index] = truncateToWidth(` ${view.title}`, width);
262
- }
263
- }
264
- return lines;
265
- }
266
- invalidate() {
267
- this.#stack.invalidate();
268
- }
269
- }
@@ -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
- }