@aiwayds/dsh-tui-pi 0.1.1 → 0.4.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 (62) hide show
  1. package/README.md +175 -41
  2. package/lib/agent-manager.d.ts +125 -0
  3. package/lib/agent-manager.js +298 -0
  4. package/lib/agent-manager.js.map +1 -0
  5. package/lib/agents.d.ts +30 -0
  6. package/lib/agents.js +489 -0
  7. package/lib/agents.js.map +1 -0
  8. package/lib/append-system.d.ts +2 -0
  9. package/lib/append-system.js +1 -1
  10. package/lib/append-system.js.map +1 -1
  11. package/lib/dsh-events.d.ts +12 -0
  12. package/lib/editor.d.ts +58 -0
  13. package/lib/editor.js +92 -0
  14. package/lib/editor.js.map +1 -1
  15. package/lib/footer.d.ts +46 -0
  16. package/lib/footer.js +65 -0
  17. package/lib/footer.js.map +1 -1
  18. package/lib/hotkeys.d.ts +93 -0
  19. package/lib/hotkeys.js +305 -0
  20. package/lib/hotkeys.js.map +1 -0
  21. package/lib/index.js +342 -61
  22. package/lib/index.js.map +1 -1
  23. package/lib/keymap.d.ts +243 -0
  24. package/lib/keymap.js +245 -0
  25. package/lib/keymap.js.map +1 -0
  26. package/lib/live-widgets.d.ts +59 -49
  27. package/lib/live-widgets.js +174 -117
  28. package/lib/live-widgets.js.map +1 -1
  29. package/lib/panels.d.ts +177 -0
  30. package/lib/panels.js +331 -0
  31. package/lib/panels.js.map +1 -0
  32. package/lib/selectors.d.ts +12 -1
  33. package/lib/selectors.js +1 -1
  34. package/lib/selectors.js.map +1 -1
  35. package/lib/session.d.ts +25 -0
  36. package/lib/session.js +173 -12
  37. package/lib/session.js.map +1 -1
  38. package/lib/settings.d.ts +1 -2
  39. package/lib/subagent-policy.d.ts +88 -0
  40. package/lib/subagent-policy.js +189 -0
  41. package/lib/subagent-policy.js.map +1 -0
  42. package/lib/subagent-viewer.d.ts +49 -0
  43. package/lib/subagent-viewer.js +523 -0
  44. package/lib/subagent-viewer.js.map +1 -0
  45. package/lib/theme/index.js +4 -0
  46. package/lib/theme/index.js.map +1 -1
  47. package/lib/theme/palette.d.ts +21 -3
  48. package/lib/theme/palette.js +27 -6
  49. package/lib/theme/palette.js.map +1 -1
  50. package/lib/theme-settings.d.ts +71 -9
  51. package/lib/theme-settings.js +145 -21
  52. package/lib/theme-settings.js.map +1 -1
  53. package/lib/tui.d.ts +23 -9
  54. package/lib/tui.js +110 -7
  55. package/lib/tui.js.map +1 -1
  56. package/package.json +3 -4
  57. package/patches/@earendil-works__pi-tui.patch +206 -2
  58. package/scripts/dev-install.mjs +87 -0
  59. package/scripts/link-dsh-closure.mjs +85 -0
  60. package/templates/agents/ArtyDuck.md +11 -0
  61. package/templates/agents/oldfox.md +33 -0
  62. package/templates/agents/workhorse.md +34 -0
@@ -9,13 +9,12 @@
9
9
  * renders zero rows while empty and grows to its content while the model
10
10
  * has todos.
11
11
  * - `activityDoc` — the lastRequest container below the editor
12
- * (`ui.lastRequest`): the ` ↳ <last request>` line (persisting across
12
+ * (`ui.lastRequest`): the ` ● <last request>` line (persisting across
13
13
  * agent churn) followed by one compact line PER RUNNING agent. NO box
14
- * chrome, NO `● Agents` header, NO connector and NO provider — just
15
- * ` ↳ ⠋ <name> · ↻N≤M · 562 token (4%) · 13.6s`, the ` ↳ ` prefix
16
- * nesting one column right of the request line's ` ↳ ` (a tree level
17
- * under the last request) so the agent NAMES align under that level.
18
- * Auto height — it
14
+ * chrome, NO `● Agents` header, NO provider — just
15
+ * `├─ ⠋ <name> · ↻N≤M · 21k/1m · 13.6s · <latest output>`, the `├─ ` /
16
+ * `└─ ` prefix in the same column as the todo rows and the request ` ● `
17
+ * (the last running agent closes the list with `└─ `). Auto height — it
19
18
  * collapses to zero rows when both the last-request line is cleared and no
20
19
  * agent runs.
21
20
  *
@@ -30,41 +29,64 @@
30
29
  * `setTheme` recolors in place on a theme hot-switch (no replay buffer — the
31
30
  * widget is live state, not transcript history).
32
31
  */
33
- import { Container } from '@earendil-works/pi-tui';
32
+ import { Container, type Component } from '@earendil-works/pi-tui';
34
33
  import type { AgentView } from './dsh-events.ts';
35
34
  import { type TuiTheme } from './theme/index.ts';
36
35
  /** Refresh interval of the live running-agent lines (spinner + elapsed). */
37
36
  export declare const AGENT_TICK_MS = 100;
38
37
  /** Braille spinner cycle; the frame index advances once per tick. */
39
38
  export declare const AGENT_SPINNER_FRAMES: readonly ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
+ /**
40
+ * Compact human-readable size: 1_500_000 -> `1.5m`, 20_965 -> `20k`, 999 -> `999`.
41
+ * Millions keep one decimal (a trailing `.0` dropped), thousands floor to `k`.
42
+ */
43
+ export declare function fmtCompact(n: number): string;
44
+ /**
45
+ * The live Todos panel - a self-drawing table on the panel framework
46
+ * (padCell/columnWidths from panels.ts), rendered at the CURRENT width on
47
+ * every frame. pi-tui's Container calls `render(width)` per frame with no
48
+ * caching, so a terminal resize re-lays the table out (clip + column widths)
49
+ * automatically - no stale baked rows, no word-wrap. Renders zero rows while
50
+ * the list is empty or every todo is completed (clear-when-done).
51
+ */
52
+ export declare class TodosPanel implements Component {
53
+ private todos;
54
+ private readonly getTheme;
55
+ constructor(getTheme: () => TuiTheme);
56
+ invalidate(): void;
57
+ /** Replace the todos snapshot (`todo/write` events, replay, /new). */
58
+ setTodos(todos: readonly {
59
+ content: string;
60
+ status: string;
61
+ }[]): void;
62
+ render(width: number): string[];
63
+ }
40
64
  export declare class LiveWidgets {
41
65
  private readonly todosDoc;
42
66
  private readonly activityDoc;
43
67
  private theme;
44
68
  private readonly requestRender;
45
- /** Latest todo list from `todo/write`, rendered while non-empty. */
46
- private liveTodos;
69
+ /** The self-drawing Todos table, mounted once in `todosDoc`. */
70
+ private readonly todosPanel;
47
71
  /** Latest subagent views from the bridge's onLive fold. */
48
72
  private liveAgents;
49
- /** The Todos panel Text in `todosDoc`, replaced on rebuild. */
50
- private todosText;
51
73
  /** The merged running-agent Text in `activityDoc`, replaced on rebuild. */
52
74
  private agentsText;
53
- /** The ` ↳ <last request>` Text in `activityDoc` (persists across churn). */
75
+ /** The ` ● <last request>` Text in `activityDoc` (persists across churn). */
54
76
  private requestText;
55
77
  /** Plain clipped last-request text backing `requestText` (for setTheme). */
56
78
  private requestDisplay;
57
79
  /** Spinner frame counter, advanced by tickLive while any agent runs. */
58
80
  private spinnerFrame;
59
81
  /**
60
- * @param todosDoc The dock slot ABOVE the chat input — the Todos boxed
82
+ * @param todosDoc The dock slot ABOVE the chat input - the Todos table
61
83
  * panel lives here.
62
- * @param activityDoc The lastRequest container BELOW the editor — the ` ↳ `
84
+ * @param activityDoc The lastRequest container BELOW the editor - the ` ● `
63
85
  * last-request line plus the compact running-agent lines live here.
64
86
  */
65
87
  constructor(todosDoc: Container, activityDoc: Container, theme: TuiTheme, requestRender: () => void);
66
88
  /**
67
- * Render the ` ↳ <text>` last-request line in `activityDoc`, UNDER which the
89
+ * Render the ` ● <text>` last-request line in `activityDoc`, UNDER which the
68
90
  * running-agent lines appear. `undefined` (or blank) removes the line. Same
69
91
  * styling as before the merge: `fgMuted` prefix, but the text is clipped to
70
92
  * the terminal width (`columns - 5`) so it ALWAYS renders on one row and
@@ -84,50 +106,38 @@ export declare class LiveWidgets {
84
106
  * — the activity area then holds its final (empty) state.
85
107
  */
86
108
  tickLive(): void;
87
- /** Recolor the widget (Todos panel, ↳ line and agent lines) under a theme
88
- * hot-switch (no-op on the same bundle). */
109
+ /** Recolor the widget (Todos panel, ● line and agent lines) under a theme
110
+ * hot-switch (no-op on the same bundle). The Todos panel reads the theme
111
+ * through its getter, so the swap needs only a repaint. */
89
112
  setTheme(theme: TuiTheme): void;
90
113
  /**
91
114
  * Drop everything except the last-request echo (`/new`): clears the Todos
92
- * panel and the running-agent lines, keeps the ` ↳ ` line. The bridge also
115
+ * panel and the running-agent lines, keeps the ` ● ` line. The bridge also
93
116
  * fires `renderAgents([])`.
94
117
  */
95
118
  clear(): void;
96
119
  /**
97
- * Rebuild the two live surfaces:
98
- * - `todosDoc`: one bordered Todos panel (or nothing when all completed).
99
- * - `activityDoc`: the ` ↳ ` line (managed separately, persists) followed
100
- * by ONE Text holding the compact running-agent lines joined by '\n'
101
- * (or nothing when no agent runs — the slot collapses to the ↳ line).
102
- * Clear-when-done: the Todos panel hides once every todo is completed (the
103
- * model writes the whole-list snapshot and rarely clears it — an
104
- * all-completed list is the end-of-work signal); the agent lines hide once
105
- * no child is running.
120
+ * Rebuild the running-agent surface of `activityDoc`: the ` ● ` line
121
+ * (managed separately, persists) followed by ONE Text holding the compact
122
+ * running-agent lines joined by '\n' (or nothing when no agent runs - the
123
+ * slot collapses to the ● line). The Todos table panel is NOT rebuilt
124
+ * here - it is a self-drawing component that re-renders each frame.
125
+ * Clear-when-done: the agent lines hide once no child is running.
106
126
  */
107
127
  private rebuild;
108
- /** One bordered panel: top border + header row + body rows + bottom border. */
109
- private boxedPanel;
110
- /** `● Todos (done/total)`, styled for the panel header row. */
111
- private todosHeader;
112
- /**
113
- * Tree-style todo body lines: `├─`/`└─` connectors with `☐`/`◐`/`☑` status
114
- * icons. Content is model-controlled: clipped BEFORE styling to the boxed
115
- * row's inner budget minus the tree chrome (connector 3 cols + icon+space
116
- * 2 cols) — see clipPanelLine's contract.
117
- */
118
- private todoLines;
119
128
  /**
120
- * One compact running-agent line for the last-request area: ` ↳ ` prefix
121
- * (nesting one column right of the ` ↳ ` request text, like a tree level
122
- * under the last request) + spinner + the agent NAME
123
- * (`view.label`, matching the boxed panel's main line) + the exact meta that
124
- * main line showed (`↻retries≤max`, `N token (%)`, elapsed). NO box chrome,
125
- * NO connector, NO provider — the name is the prominent element. The label
126
- * is the only unbounded field; it is clipped against a budget measured from
127
- * the terminal width so the whole plain line (prefix included) fits, then
128
- * the assembled plain line is clipped to the terminal width BEFORE any ANSI
129
- * is applied (clipToWidth counts SGR fragments as visible columns — style
130
- * last). The provider (`view.provider`) is dropped from this line.
129
+ * One compact running-agent line for the last-request area, todo-style:
130
+ * `├─ `/`└─ ` connector (same column as the todo rows and the request
131
+ * ` ● `) + spinner + the agent NAME (`view.label`, matching the boxed
132
+ * panel's main line) + the exact meta that main line showed (`↻retries≤max`,
133
+ * compact `tokens[/contextWindow]`, elapsed) + the child's latest output
134
+ * line when one exists (` · <tail>`, so the user sees it is alive). NO box
135
+ * chrome, NO provider — the name is the prominent element; the last running
136
+ * agent closes the list with `└─ `. The label is the only unbounded field;
137
+ * it is clipped against a budget measured from the terminal width so the
138
+ * whole plain line (prefix included) fits, then the assembled plain line is
139
+ * clipped to the terminal width BEFORE any ANSI is applied (clipToWidth
140
+ * counts SGR fragments as visible columns — style last).
131
141
  */
132
142
  private compactAgentLine;
133
143
  }
@@ -9,13 +9,12 @@
9
9
  * renders zero rows while empty and grows to its content while the model
10
10
  * has todos.
11
11
  * - `activityDoc` — the lastRequest container below the editor
12
- * (`ui.lastRequest`): the ` ↳ <last request>` line (persisting across
12
+ * (`ui.lastRequest`): the ` ● <last request>` line (persisting across
13
13
  * agent churn) followed by one compact line PER RUNNING agent. NO box
14
- * chrome, NO `● Agents` header, NO connector and NO provider — just
15
- * ` ↳ ⠋ <name> · ↻N≤M · 562 token (4%) · 13.6s`, the ` ↳ ` prefix
16
- * nesting one column right of the request line's ` ↳ ` (a tree level
17
- * under the last request) so the agent NAMES align under that level.
18
- * Auto height — it
14
+ * chrome, NO `● Agents` header, NO provider — just
15
+ * `├─ ⠋ <name> · ↻N≤M · 21k/1m · 13.6s · <latest output>`, the `├─ ` /
16
+ * `└─ ` prefix in the same column as the todo rows and the request ` ● `
17
+ * (the last running agent closes the list with `└─ `). Auto height — it
19
18
  * collapses to zero rows when both the last-request line is cleared and no
20
19
  * agent runs.
21
20
  *
@@ -31,13 +30,27 @@
31
30
  * widget is live state, not transcript history).
32
31
  */
33
32
  import { Container, Text } from '@earendil-works/pi-tui';
34
- import { panelBodyText, panelBoxWidth, clipPanelLine } from "./messages.js";
33
+ import { panelBoxWidth, clipPanelLine } from "./messages.js";
34
+ import { columnWidths, padCell, TABLE_SEP } from "./panels.js";
35
35
  import { ansiFg, RESET } from "./theme/index.js";
36
36
  import { clipToWidth, visibleWidth } from "./text.js";
37
37
  /** Refresh interval of the live running-agent lines (spinner + elapsed). */
38
38
  export const AGENT_TICK_MS = 100;
39
39
  /** Braille spinner cycle; the frame index advances once per tick. */
40
40
  export const AGENT_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
41
+ /**
42
+ * Compact human-readable size: 1_500_000 -> `1.5m`, 20_965 -> `20k`, 999 -> `999`.
43
+ * Millions keep one decimal (a trailing `.0` dropped), thousands floor to `k`.
44
+ */
45
+ export function fmtCompact(n) {
46
+ if (n >= 1_000_000) {
47
+ const m = n / 1_000_000;
48
+ return `${Number.isInteger(m) ? String(m) : m.toFixed(1).replace(/\.0$/, '')}m`;
49
+ }
50
+ if (n >= 1_000)
51
+ return `${Math.floor(n / 1_000)}k`;
52
+ return String(n);
53
+ }
41
54
  /** One bordered row of the Todos panel: `│ ` + inner + ` │` (no trailing RESET). */
42
55
  function borderedRow(boxWidth, borderFg, inner) {
43
56
  const pad = Math.max(0, boxWidth - 4 - visibleWidth(inner));
@@ -47,29 +60,104 @@ function borderedRow(boxWidth, borderFg, inner) {
47
60
  function panelTopBorder(boxWidth, borderFg) {
48
61
  return `${borderFg}┌${'─'.repeat(Math.max(0, boxWidth - 2))}┐`;
49
62
  }
63
+ /** The todos table columns: right-aligned row number, status icon, flex content. */
64
+ const TODO_COLUMNS = [
65
+ { key: 'idx', title: '#', width: 3, align: 'right' },
66
+ { key: 'status', title: '✓', width: 2 },
67
+ { key: 'content', title: 'Task', flex: true },
68
+ ];
69
+ /** Status icon of one todo: pending / in-progress / completed. */
70
+ function todoIcon(status) {
71
+ if (status === 'completed')
72
+ return '☑';
73
+ if (status === 'in_progress')
74
+ return '◐';
75
+ return '☐';
76
+ }
77
+ /**
78
+ * The live Todos panel - a self-drawing table on the panel framework
79
+ * (padCell/columnWidths from panels.ts), rendered at the CURRENT width on
80
+ * every frame. pi-tui's Container calls `render(width)` per frame with no
81
+ * caching, so a terminal resize re-lays the table out (clip + column widths)
82
+ * automatically - no stale baked rows, no word-wrap. Renders zero rows while
83
+ * the list is empty or every todo is completed (clear-when-done).
84
+ */
85
+ export class TodosPanel {
86
+ todos = [];
87
+ getTheme;
88
+ constructor(getTheme) {
89
+ this.getTheme = getTheme;
90
+ }
91
+ invalidate() { }
92
+ /** Replace the todos snapshot (`todo/write` events, replay, /new). */
93
+ setTodos(todos) {
94
+ this.todos = todos;
95
+ }
96
+ render(width) {
97
+ if (this.todos.length === 0 || this.todos.every(todo => todo.status === 'completed'))
98
+ return [];
99
+ const theme = this.getTheme();
100
+ const p = theme.palette;
101
+ const boxWidth = panelBoxWidth(width);
102
+ const borderFg = ansiFg(p.borderDefault);
103
+ const innerWidth = boxWidth - 4;
104
+ const colWidths = columnWidths(innerWidth, TODO_COLUMNS);
105
+ const subtle = (text) => ansiFg(p.fgSubtle) + text + RESET;
106
+ // Header row of the panel: `● Todos (done/total)` in the accent/subtle pair.
107
+ const done = this.todos.filter(todo => todo.status === 'completed').length;
108
+ const headerInner = ansiFg(p.accent) + '● Todos ' + RESET
109
+ + subtle(`(${done}/${this.todos.length})`);
110
+ const out = [panelTopBorder(boxWidth, borderFg), borderedRow(boxWidth, borderFg, headerInner)];
111
+ // Table header: every cell padded to its column so the rows align.
112
+ out.push(borderedRow(boxWidth, borderFg, TODO_COLUMNS.map((column, i) => padCell(column.title, colWidths[i], column.align)).join(TABLE_SEP)));
113
+ for (let i = 0; i < this.todos.length; i++) {
114
+ const todo = this.todos[i];
115
+ // Plain (clipped+padded) cells FIRST, ANSI after - the panel-line rule.
116
+ const idxCell = padCell(String(i + 1), colWidths[0], 'right');
117
+ const iconCell = padCell(todoIcon(todo.status), colWidths[1]);
118
+ const contentCell = padClip(todo.content, colWidths[2]);
119
+ // One color per status across the icon and content cells (the icon has
120
+ // its own column here - no baked-in prefix from theme.chat.todo*).
121
+ const color = todo.status === 'completed'
122
+ ? p.success
123
+ : todo.status === 'in_progress' ? p.attention : p.fgSubtle;
124
+ const statusStyle = (text) => ansiFg(color) + text + RESET;
125
+ out.push(borderedRow(boxWidth, borderFg, subtle(idxCell) + TABLE_SEP + statusStyle(iconCell) + TABLE_SEP + statusStyle(contentCell)));
126
+ }
127
+ out.push(panelBottomRow(boxWidth, borderFg));
128
+ return out;
129
+ }
130
+ }
131
+ /** Bottom border line (`└─…─┘`), `boxWidth` columns wide. */
132
+ function panelBottomRow(boxWidth, borderFg) {
133
+ return `${borderFg}└${'─'.repeat(Math.max(0, boxWidth - 2))}┘`;
134
+ }
135
+ /** Clip one todo's content (a possibly multiline model string) to one row. */
136
+ function padClip(content, width) {
137
+ const flat = content.replace(/\r/g, '').replace(/\n/g, ' ').trim();
138
+ return padCell(flat, width);
139
+ }
50
140
  export class LiveWidgets {
51
141
  todosDoc;
52
142
  activityDoc;
53
143
  theme;
54
144
  requestRender;
55
- /** Latest todo list from `todo/write`, rendered while non-empty. */
56
- liveTodos = [];
145
+ /** The self-drawing Todos table, mounted once in `todosDoc`. */
146
+ todosPanel;
57
147
  /** Latest subagent views from the bridge's onLive fold. */
58
148
  liveAgents = [];
59
- /** The Todos panel Text in `todosDoc`, replaced on rebuild. */
60
- todosText;
61
149
  /** The merged running-agent Text in `activityDoc`, replaced on rebuild. */
62
150
  agentsText;
63
- /** The ` ↳ <last request>` Text in `activityDoc` (persists across churn). */
151
+ /** The ` ● <last request>` Text in `activityDoc` (persists across churn). */
64
152
  requestText;
65
153
  /** Plain clipped last-request text backing `requestText` (for setTheme). */
66
154
  requestDisplay;
67
155
  /** Spinner frame counter, advanced by tickLive while any agent runs. */
68
156
  spinnerFrame = 0;
69
157
  /**
70
- * @param todosDoc The dock slot ABOVE the chat input — the Todos boxed
158
+ * @param todosDoc The dock slot ABOVE the chat input - the Todos table
71
159
  * panel lives here.
72
- * @param activityDoc The lastRequest container BELOW the editor — the ` ↳ `
160
+ * @param activityDoc The lastRequest container BELOW the editor - the ` ● `
73
161
  * last-request line plus the compact running-agent lines live here.
74
162
  */
75
163
  constructor(todosDoc, activityDoc, theme, requestRender) {
@@ -77,9 +165,14 @@ export class LiveWidgets {
77
165
  this.activityDoc = activityDoc;
78
166
  this.theme = theme;
79
167
  this.requestRender = requestRender;
168
+ // Mounted once: the panel re-renders at the live width every frame and
169
+ // reads the theme through the getter, so theme swaps and terminal
170
+ // resizes need no rebuild wiring here.
171
+ this.todosPanel = new TodosPanel(() => this.theme);
172
+ todosDoc.addChild(this.todosPanel);
80
173
  }
81
174
  /**
82
- * Render the ` ↳ <text>` last-request line in `activityDoc`, UNDER which the
175
+ * Render the ` ● <text>` last-request line in `activityDoc`, UNDER which the
83
176
  * running-agent lines appear. `undefined` (or blank) removes the line. Same
84
177
  * styling as before the merge: `fgMuted` prefix, but the text is clipped to
85
178
  * the terminal width (`columns - 5`) so it ALWAYS renders on one row and
@@ -95,14 +188,14 @@ export class LiveWidgets {
95
188
  }
96
189
  return;
97
190
  }
98
- // One-row budget: paddingX=1 on each side (2 cols) + the ` ↳ ` prefix (3
191
+ // One-row budget: paddingX=1 on each side (2 cols) + the ` ● ` prefix (3
99
192
  // cols) → the plain `display` must fit `columns - 5`. Non-TTY fallback
100
193
  // keeps the old 200-col behavior.
101
194
  const budget = Math.max(1, (process.stdout.columns ?? 200) - 5);
102
195
  const display = clipToWidth(text, budget);
103
196
  if (this.requestText === undefined) {
104
197
  this.requestText = new Text('', 1, 0);
105
- // Keep the ↳ line ABOVE the running-agent lines regardless of whether
198
+ // Keep the ● line ABOVE the running-agent lines regardless of whether
106
199
  // they already render (no insert-at on a plain Container — remove and
107
200
  // re-add both in the right order).
108
201
  if (this.agentsText !== undefined)
@@ -112,13 +205,13 @@ export class LiveWidgets {
112
205
  this.activityDoc.addChild(this.agentsText);
113
206
  }
114
207
  this.requestDisplay = display;
115
- this.requestText.setText(ansiFg(this.theme.palette.fgMuted) + ` ↳ ${display}` + RESET);
208
+ this.requestText.setText(ansiFg(this.theme.palette.fgMuted) + ` ● ${display}` + RESET);
116
209
  this.requestRender();
117
210
  }
118
211
  /** Replace the todos snapshot (`todo/write` events and replay). */
119
212
  renderTodos(todos) {
120
- this.liveTodos = todos;
121
- this.rebuild();
213
+ this.todosPanel.setTodos(todos);
214
+ this.requestRender();
122
215
  }
123
216
  /** Replace the subagent views (the bridge's onLive fold). */
124
217
  renderAgents(agents) {
@@ -136,32 +229,29 @@ export class LiveWidgets {
136
229
  this.spinnerFrame += 1;
137
230
  this.rebuild();
138
231
  }
139
- /** Recolor the widget (Todos panel, ↳ line and agent lines) under a theme
140
- * hot-switch (no-op on the same bundle). */
232
+ /** Recolor the widget (Todos panel, ● line and agent lines) under a theme
233
+ * hot-switch (no-op on the same bundle). The Todos panel reads the theme
234
+ * through its getter, so the swap needs only a repaint. */
141
235
  setTheme(theme) {
142
236
  if (theme === this.theme)
143
237
  return;
144
238
  this.theme = theme;
145
239
  this.rebuild();
146
- // The ↳ line is not part of rebuild() (it persists independent of the
240
+ // The request line is not part of rebuild() (it persists independent of the
147
241
  // agent lines); recolor it here from the cached plain text.
148
242
  if (this.requestDisplay !== undefined && this.requestText !== undefined) {
149
- this.requestText.setText(ansiFg(this.theme.palette.fgMuted) + ` ↳ ${this.requestDisplay}` + RESET);
243
+ this.requestText.setText(ansiFg(this.theme.palette.fgMuted) + ` ● ${this.requestDisplay}` + RESET);
150
244
  }
151
245
  }
152
246
  /**
153
247
  * Drop everything except the last-request echo (`/new`): clears the Todos
154
- * panel and the running-agent lines, keeps the ` ↳ ` line. The bridge also
248
+ * panel and the running-agent lines, keeps the ` ● ` line. The bridge also
155
249
  * fires `renderAgents([])`.
156
250
  */
157
251
  clear() {
158
- this.liveTodos = [];
159
252
  this.liveAgents = [];
160
253
  this.spinnerFrame = 0;
161
- if (this.todosText !== undefined) {
162
- this.todosDoc.removeChild(this.todosText);
163
- this.todosText = undefined;
164
- }
254
+ this.todosPanel.setTodos([]);
165
255
  if (this.agentsText !== undefined) {
166
256
  this.activityDoc.removeChild(this.agentsText);
167
257
  this.agentsText = undefined;
@@ -169,38 +259,24 @@ export class LiveWidgets {
169
259
  this.requestRender();
170
260
  }
171
261
  /**
172
- * Rebuild the two live surfaces:
173
- * - `todosDoc`: one bordered Todos panel (or nothing when all completed).
174
- * - `activityDoc`: the ` ↳ ` line (managed separately, persists) followed
175
- * by ONE Text holding the compact running-agent lines joined by '\n'
176
- * (or nothing when no agent runs — the slot collapses to the ↳ line).
177
- * Clear-when-done: the Todos panel hides once every todo is completed (the
178
- * model writes the whole-list snapshot and rarely clears it — an
179
- * all-completed list is the end-of-work signal); the agent lines hide once
180
- * no child is running.
262
+ * Rebuild the running-agent surface of `activityDoc`: the ` ● ` line
263
+ * (managed separately, persists) followed by ONE Text holding the compact
264
+ * running-agent lines joined by '\n' (or nothing when no agent runs - the
265
+ * slot collapses to the ● line). The Todos table panel is NOT rebuilt
266
+ * here - it is a self-drawing component that re-renders each frame.
267
+ * Clear-when-done: the agent lines hide once no child is running.
181
268
  */
182
269
  rebuild() {
183
- if (this.liveTodos.some(todo => todo.status !== 'completed')) {
184
- const panel = this.boxedPanel(this.todosHeader(), this.todoLines());
185
- if (this.todosText !== undefined)
186
- this.todosDoc.removeChild(this.todosText);
187
- this.todosText = new Text(panel, 1, 0);
188
- this.todosDoc.addChild(this.todosText);
189
- }
190
- else if (this.todosText !== undefined) {
191
- this.todosDoc.removeChild(this.todosText);
192
- this.todosText = undefined;
193
- }
194
270
  const running = this.liveAgents.filter(view => view.outcome === undefined);
195
271
  if (running.length > 0) {
196
- const lines = running.map(view => this.compactAgentLine(view));
272
+ const lines = running.map((view, i) => this.compactAgentLine(view, i === running.length - 1));
197
273
  const text = lines.join('\n');
198
274
  if (this.agentsText !== undefined) {
199
275
  this.agentsText.setText(text);
200
276
  }
201
277
  else {
202
278
  this.agentsText = new Text(text, 1, 0);
203
- // Append under the ↳ line when it is present (plain Container appends).
279
+ // Append under the ● line when it is present (plain Container appends).
204
280
  this.activityDoc.addChild(this.agentsText);
205
281
  }
206
282
  }
@@ -210,64 +286,24 @@ export class LiveWidgets {
210
286
  }
211
287
  this.requestRender();
212
288
  }
213
- /** One bordered panel: top border + header row + body rows + bottom border. */
214
- boxedPanel(headerInner, bodyLines) {
215
- const boxWidth = panelBoxWidth(process.stdout.columns);
216
- const borderFg = ansiFg(this.theme.palette.borderDefault);
217
- const top = panelTopBorder(boxWidth, borderFg);
218
- const header = borderedRow(boxWidth, borderFg, clipPanelLine(headerInner));
219
- // 'all' body: every line kept verbatim (each already clipped to one
220
- // physical row), bottom border appended.
221
- const body = panelBodyText(bodyLines, boxWidth, borderFg, 'all');
222
- return `${top}\n${header}\n${body}`;
223
- }
224
- /** `● Todos (done/total)`, styled for the panel header row. */
225
- todosHeader() {
226
- const done = this.liveTodos.filter(todo => todo.status === 'completed').length;
227
- return ansiFg(this.theme.palette.accent) + '● Todos ' + RESET
228
- + ansiFg(this.theme.palette.fgSubtle) + `(${done}/${this.liveTodos.length})` + RESET;
229
- }
230
289
  /**
231
- * Tree-style todo body lines: `├─`/`└─` connectors with `☐`/`◐`/`☑` status
232
- * icons. Content is model-controlled: clipped BEFORE styling to the boxed
233
- * row's inner budget minus the tree chrome (connector 3 cols + icon+space
234
- * 2 cols) — see clipPanelLine's contract.
290
+ * One compact running-agent line for the last-request area, todo-style:
291
+ * `├─ `/`└─ ` connector (same column as the todo rows and the request
292
+ * ` ● `) + spinner + the agent NAME (`view.label`, matching the boxed
293
+ * panel's main line) + the exact meta that main line showed (`↻retries≤max`,
294
+ * compact `tokens[/contextWindow]`, elapsed) + the child's latest output
295
+ * line when one exists (` · <tail>`, so the user sees it is alive). NO box
296
+ * chrome, NO provider — the name is the prominent element; the last running
297
+ * agent closes the list with `└─ `. The label is the only unbounded field;
298
+ * it is clipped against a budget measured from the terminal width so the
299
+ * whole plain line (prefix included) fits, then the assembled plain line is
300
+ * clipped to the terminal width BEFORE any ANSI is applied (clipToWidth
301
+ * counts SGR fragments as visible columns — style last).
235
302
  */
236
- todoLines() {
237
- const innerCap = panelBoxWidth(process.stdout.columns) - 4;
238
- const lines = [];
239
- const total = this.liveTodos.length;
240
- for (let i = 0; i < total; i++) {
241
- const todo = this.liveTodos[i];
242
- const content = clipToWidth(todo.content, Math.max(10, innerCap - 5));
243
- const connector = ansiFg(this.theme.palette.fgSubtle) + (i === total - 1 ? '└─ ' : '├─ ') + RESET;
244
- const statusStyled = todo.status === 'completed'
245
- ? this.theme.chat.todoDone(content)
246
- : todo.status === 'in_progress'
247
- ? ansiFg(this.theme.palette.attention) + `◐ ${content}` + RESET
248
- : this.theme.chat.todoOpen(content);
249
- lines.push(connector + statusStyled);
250
- }
251
- return lines;
252
- }
253
- /**
254
- * One compact running-agent line for the last-request area: ` ↳ ` prefix
255
- * (nesting one column right of the ` ↳ ` request text, like a tree level
256
- * under the last request) + spinner + the agent NAME
257
- * (`view.label`, matching the boxed panel's main line) + the exact meta that
258
- * main line showed (`↻retries≤max`, `N token (%)`, elapsed). NO box chrome,
259
- * NO connector, NO provider — the name is the prominent element. The label
260
- * is the only unbounded field; it is clipped against a budget measured from
261
- * the terminal width so the whole plain line (prefix included) fits, then
262
- * the assembled plain line is clipped to the terminal width BEFORE any ANSI
263
- * is applied (clipToWidth counts SGR fragments as visible columns — style
264
- * last). The provider (`view.provider`) is dropped from this line.
265
- */
266
- compactAgentLine(view) {
267
- // two spaces + ↳ + space: nests one column right of the request line's
268
- // ` ↳ ` (the request renders as paddingX(1) + ` ↳ `), like a tree level
269
- // under the last request, so the agent names align under that level.
270
- const PREFIX = ' ↳ ';
303
+ compactAgentLine(view, isLast) {
304
+ // Todo-style tree connector: `├─ ` for non-final rows, `└─ ` for the last
305
+ // running agent — aligned with the todo rows and the request ` ● `.
306
+ const PREFIX = isLast ? '└─ ' : '├─ ';
271
307
  const width = Math.max(1, process.stdout.columns ?? 80);
272
308
  const glyph = AGENT_SPINNER_FRAMES[this.spinnerFrame % AGENT_SPINNER_FRAMES.length];
273
309
  const metaParts = [];
@@ -275,20 +311,39 @@ export class LiveWidgets {
275
311
  metaParts.push(view.maxRetries === undefined ? `↻${view.retries}` : `↻${view.retries}≤${view.maxRetries}`);
276
312
  }
277
313
  if (view.tokens > 0) {
278
- const pct = typeof view.contextWindow === 'number' && view.contextWindow > 0
279
- ? ` (${Math.round(view.tokens / view.contextWindow * 100)}%)`
314
+ // Compact tokens[/contextWindow]: `21k/1m` — no percent, no "token".
315
+ const ctx = typeof view.contextWindow === 'number' && view.contextWindow > 0
316
+ ? `/${fmtCompact(view.contextWindow)}`
280
317
  : '';
281
- metaParts.push(`${view.tokens} token${pct}`);
318
+ metaParts.push(`${fmtCompact(view.tokens)}${ctx}`);
282
319
  }
283
320
  const elapsed = (Date.now() - view.startedAt) / 1000;
284
321
  metaParts.push(`${elapsed.toFixed(1)}s`);
285
- const metaPlain = ' · ' + metaParts.join(' · ');
286
- // Defensive: never render an empty name — fall back to the child id.
322
+ let metaPlain = ' · ' + metaParts.join(' · ');
323
+ // Tail: the child's latest visible output line, appended after the elapsed
324
+ // column so the user knows it is alive. Budget ≤30% of the terminal width
325
+ // (clamped 20..60 cols); when the folded line is wider, clip to
326
+ // tailBudget-2 and end with `..` (clipToWidth's own `…` is stripped).
327
+ const lastLine = view.lastLine;
328
+ if (lastLine !== undefined && lastLine !== '') {
329
+ const folded = lastLine.replace(/\s+/g, ' ').trim();
330
+ if (folded !== '') {
331
+ const tailBudget = Math.min(60, Math.max(20, Math.floor(width * 0.3)));
332
+ if (visibleWidth(folded) > tailBudget) {
333
+ const clipped = clipToWidth(folded, tailBudget - 2).replace(/…$/, '');
334
+ metaPlain += ` · ${clipped}..`;
335
+ }
336
+ else {
337
+ metaPlain += ` · ${folded}`;
338
+ }
339
+ }
340
+ }
341
+ // Defensive: never render an empty name — fall back to `subagent`.
287
342
  const rawLabel = clipPanelLine(view.label, 0).replace(/\r/g, '').trim();
288
343
  const namePlain = rawLabel === ''
289
- ? `subagent ${(view.childId ?? '').slice(0, 8)}`
344
+ ? 'subagent'
290
345
  : rawLabel;
291
- // Fixed chrome: prefix (4) + spinner (1) + following space (1) + a safety
346
+ // Fixed chrome: prefix (3) + spinner (1) + following space (1) + a safety
292
347
  // column. The label takes the remainder of the width budget after meta.
293
348
  const fixed = visibleWidth(PREFIX) + 1 + 1 + 1;
294
349
  const nameBudget = width - fixed - visibleWidth(metaPlain);
@@ -297,11 +352,13 @@ export class LiveWidgets {
297
352
  const plain = PREFIX + glyph + ' ' + nameClipped + metaPlain;
298
353
  const clipped = clipToWidth(plain, width);
299
354
  // Style segments from the (clipped, guaranteed-identical-when-it-fits)
300
- // plain pieces: spinner muted, NAME default (prominent), meta subtle.
355
+ // plain pieces: connector subtle, spinner muted, NAME default (prominent),
356
+ // meta (incl. the tail) subtle.
357
+ const connector = ansiFg(this.theme.palette.fgSubtle) + PREFIX + RESET;
301
358
  const spinner = ansiFg(this.theme.palette.fgMuted) + glyph + RESET;
302
359
  const metaStyled = ansiFg(this.theme.palette.fgSubtle) + metaPlain + RESET;
303
360
  const nameStyled = ansiFg(this.theme.palette.fgDefault) + nameClipped + RESET;
304
- const base = PREFIX + spinner + ' ' + nameStyled + metaStyled;
361
+ const base = connector + spinner + ' ' + nameStyled + metaStyled;
305
362
  // If clipping truncated the plain line (pathological meta), re-clip the
306
363
  // assembled styled line's visible width too; otherwise base is exact.
307
364
  if (visibleWidth(clipped) < visibleWidth(plain)) {