@tt-a1i/openpi 0.3.1 → 0.5.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 (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -3,8 +3,12 @@ import {
3
3
  keyHint,
4
4
  type Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { Markdown, Text } from "@earendil-works/pi-tui";
7
- import { sanitizeText } from "./transcript.ts";
6
+ import {
7
+ type Component,
8
+ Markdown,
9
+ truncateToWidth,
10
+ } from "@earendil-works/pi-tui";
11
+ import { sanitizeText } from "../../../shared/agent-transcript.ts";
8
12
 
9
13
  const MAX_STATUS_ROWS = 4;
10
14
 
@@ -12,40 +16,116 @@ export interface WaitResultItem {
12
16
  readonly id: string;
13
17
  readonly title?: string;
14
18
  readonly status?: string;
19
+ readonly outcome?: "completed" | "failed" | "interrupted";
20
+ readonly worktreeBranch?: string;
21
+ readonly elapsed?: string;
22
+ readonly artifactSaveFailed?: boolean;
23
+ readonly fullResultSaved?: boolean;
15
24
  }
16
25
 
17
26
  export interface WaitResultDetails {
18
27
  readonly results?: readonly WaitResultItem[];
19
28
  }
20
29
 
30
+ function singleLine(value: string) {
31
+ return sanitizeText(value).replace(/\s+/gu, " ").trim();
32
+ }
33
+
34
+ function fixedRows(rows: readonly string[]): Component {
35
+ return {
36
+ render(width) {
37
+ return rows.map((row) => truncateToWidth(row, Math.max(1, width), "…"));
38
+ },
39
+ invalidate() {},
40
+ };
41
+ }
42
+
43
+ function selectStatusRows(results: readonly WaitResultItem[]) {
44
+ const visible = results.slice(0, MAX_STATUS_ROWS);
45
+ if (visible.some((result) => result.status === "error")) {
46
+ return visible;
47
+ }
48
+
49
+ const hiddenFailure = results
50
+ .slice(MAX_STATUS_ROWS)
51
+ .find((result) => result.status === "error");
52
+ return hiddenFailure ? [...visible.slice(0, -1), hiddenFailure] : visible;
53
+ }
54
+
21
55
  export function buildWaitResultPreview(
22
56
  content: string,
23
57
  details: WaitResultDetails | undefined,
24
58
  theme: Theme,
25
59
  ) {
26
60
  const results = details?.results ?? [];
27
- const failed = results.filter((result) => result.status === "error").length;
61
+ const uncertain = results.filter(
62
+ (result) => result.outcome === "interrupted",
63
+ ).length;
64
+ const failed = results.filter(
65
+ (result) => result.status === "error" && result.outcome !== "interrupted",
66
+ ).length;
67
+ const artifactFailures = results.filter(
68
+ (result) => result.artifactSaveFailed,
69
+ ).length;
70
+ const exceptions = [
71
+ failed > 0 ? theme.fg("error", `${failed} failed`) : "",
72
+ uncertain > 0 ? theme.fg("warning", `${uncertain} uncertain`) : "",
73
+ ].filter(Boolean);
28
74
  const header =
29
- theme.fg(failed > 0 ? "warning" : "success", "■") +
30
- ` ${theme.fg("accent", theme.bold(`${results.length} subagent${results.length === 1 ? "" : "s"} settled`))}` +
31
- (failed > 0 ? theme.fg("error", ` · ${failed} failed`) : "");
75
+ theme.fg(
76
+ failed > 0 || uncertain > 0 ? "warning" : "success",
77
+ failed > 0 || uncertain > 0 ? "!" : "",
78
+ ) +
79
+ ` ${exceptions.length > 0 ? `${exceptions.join(" · ")} · ` : ""}` +
80
+ theme.fg(
81
+ "accent",
82
+ theme.bold(
83
+ `${results.length} subagent${results.length === 1 ? "" : "s"} settled`,
84
+ ),
85
+ ) +
86
+ (artifactFailures > 0
87
+ ? theme.fg(
88
+ "warning",
89
+ ` · ${artifactFailures} artifact${artifactFailures === 1 ? "" : "s"} not saved`,
90
+ )
91
+ : "");
32
92
  const lines = [header];
93
+ const statusRows = selectStatusRows(results);
33
94
 
34
- for (const result of results.slice(0, MAX_STATUS_ROWS)) {
35
- const isFailure = result.status === "error";
95
+ for (const result of statusRows) {
96
+ const isUncertain = result.outcome === "interrupted";
97
+ const isFailure = result.status === "error" && !isUncertain;
36
98
  const icon = theme.fg(
37
- isFailure ? "error" : "success",
38
- isFailure ? "x" : "✓",
99
+ isFailure ? "error" : isUncertain ? "warning" : "success",
100
+ isFailure ? "x" : isUncertain ? "?" : "✓",
39
101
  );
102
+ const id = singleLine(result.id);
103
+ const title = result.title ? singleLine(result.title) : "";
104
+ const status = isUncertain
105
+ ? "uncertain"
106
+ : singleLine(result.status ?? "settled");
107
+ const elapsed = result.elapsed ? singleLine(result.elapsed) : "";
108
+ const notices = [
109
+ result.artifactSaveFailed ? "artifact not saved" : "",
110
+ result.worktreeBranch
111
+ ? `worktree handoff · ${singleLine(result.worktreeBranch)}`
112
+ : "",
113
+ result.fullResultSaved ? "full result saved" : "",
114
+ ].filter(Boolean);
115
+ const attention = notices.length
116
+ ? theme.fg("warning", `${notices.join(" · ")} · `)
117
+ : "";
40
118
  lines.push(
41
- ` ${icon} ${theme.fg("accent", result.id)}${result.title ? theme.fg("muted", ` · ${result.title}`) : ""}${theme.fg("dim", ` · ${result.status ?? "settled"}`)}`,
119
+ ` ${icon} ${attention}${theme.fg("accent", id)}${title ? theme.fg("muted", ` · ${title}`) : ""}${theme.fg("dim", ` · ${status}${elapsed ? ` · ${elapsed}` : ""}`)}`,
42
120
  );
43
121
  }
44
- if (results.length > MAX_STATUS_ROWS) {
45
- lines.push(theme.fg("dim", ` … ${results.length - MAX_STATUS_ROWS} more`));
122
+ if (results.length > statusRows.length) {
123
+ lines.push(
124
+ theme.fg("dim", ` … ${results.length - statusRows.length} more`),
125
+ );
46
126
  }
47
127
 
48
- if (content.trim()) {
128
+ if (sanitizeText(content).trim()) {
49
129
  lines.push(
50
130
  theme.fg(
51
131
  "dim",
@@ -56,6 +136,14 @@ export function buildWaitResultPreview(
56
136
  return lines.join("\n");
57
137
  }
58
138
 
139
+ export function renderWaitResultPreview(
140
+ content: string,
141
+ details: WaitResultDetails | undefined,
142
+ theme: Theme,
143
+ ) {
144
+ return fixedRows(buildWaitResultPreview(content, details, theme).split("\n"));
145
+ }
146
+
59
147
  export function renderWaitResult(
60
148
  content: string,
61
149
  details: WaitResultDetails | undefined,
@@ -63,7 +151,7 @@ export function renderWaitResult(
63
151
  theme: Theme,
64
152
  ) {
65
153
  if (!expanded) {
66
- return new Text(buildWaitResultPreview(content, details, theme), 0, 0);
154
+ return renderWaitResultPreview(content, details, theme);
67
155
  }
68
156
 
69
157
  const markdown = new Markdown(
@@ -13,6 +13,10 @@ import {
13
13
  } from "../../shared/below-editor-navigation.ts";
14
14
 
15
15
  const FAKE_CURSOR_PATTERN = /\u001b\[7m \u001b\[(?:0|27)m/;
16
+ // Conservative idle-row band for terminal-owned CJK IME preedit. Pi's editor
17
+ // contract exposes only committed input, never a composition event, so the
18
+ // reservation cannot shrink to the frames that actually need it. A 1-cell
19
+ // hardware-cursor pad lets long preedit cover or truncate the ghost.
16
20
  const IME_PREEDIT_MIN_COLUMNS = 12;
17
21
  const IME_PREEDIT_MAX_COLUMNS = 32;
18
22
  const GHOST_MIN_COLUMNS = 8;
@@ -96,10 +100,12 @@ function ghostGeometry(lines: readonly string[], width: number) {
96
100
  .replaceAll(CURSOR_MARKER, "");
97
101
  const prefix = `${beforeCursor}${cursor[0]}`;
98
102
  const remaining = Math.max(0, width - visibleWidth(prefix));
99
- // One terminal cell must remain after the hidden hardware cursor. At
100
- // narrower widths, suppress the ghost rather than placing the cursor at the
101
- // terminal's out-of-range column width.
102
- if (remaining <= GHOST_MIN_COLUMNS) return undefined;
103
+ // Keep enough cells for a readable ghost and a conservative IME preedit
104
+ // band. At narrower widths, suppress the ghost rather than placing the
105
+ // hidden hardware cursor at the terminal's out-of-range column width.
106
+ if (remaining < GHOST_MIN_COLUMNS + IME_PREEDIT_MIN_COLUMNS) {
107
+ return undefined;
108
+ }
103
109
 
104
110
  const desiredPreedit = Math.min(
105
111
  IME_PREEDIT_MAX_COLUMNS,
@@ -34,7 +34,6 @@ import {
34
34
  type TaskToolDetails,
35
35
  } from "./ui.ts";
36
36
 
37
- const TOOL_NAMES = ["tasks_add", "tasks_update", "tasks_list"] as const;
38
37
  const TASK_WIDGET_KEY = "session-tasks-panel";
39
38
  const CONFLICT_NAMES = new Set(["todo", "TodoWrite", "update_plan"]);
40
39
  const TOOL_PURPOSE =
@@ -542,8 +541,6 @@ export default function sessionTasks(pi: ExtensionAPI) {
542
541
  });
543
542
  }
544
543
 
545
- export { TOOL_NAMES as TASK_TOOL_NAMES };
546
-
547
544
  function mutationWillCloseBatch(
548
545
  snapshot: TaskSnapshot,
549
546
  id: number,
@@ -4,15 +4,35 @@ import type {
4
4
  Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
+ type Component,
7
8
  Key,
8
9
  matchesKey,
9
10
  Text,
10
- truncateToWidth,
11
- type Component,
12
11
  type TUI,
12
+ truncateToWidth,
13
+ visibleWidth,
13
14
  } from "@earendil-works/pi-tui";
15
+ import { fitNavigationSides } from "../shared/below-editor-navigation.ts";
16
+ import {
17
+ hintLine,
18
+ panelFrame,
19
+ screenTitleLine,
20
+ } from "../shared/screen-chrome.ts";
14
21
  import type { TaskItem, TaskSnapshot } from "./tasks.ts";
15
22
 
23
+ /**
24
+ * One colour per status, shared by every surface. The widget used to paint
25
+ * in-progress amber while the full list painted it accent, so the same item
26
+ * changed colour depending on where you looked at it.
27
+ */
28
+ const STATUS_COLOR = {
29
+ pending: "muted",
30
+ in_progress: "accent",
31
+ blocked: "warning",
32
+ done: "success",
33
+ dropped: "error",
34
+ } as const satisfies Record<TaskItem["status"], string>;
35
+
16
36
  const STATUS_ICON: Record<TaskItem["status"], string> = {
17
37
  pending: "○",
18
38
  in_progress: "●",
@@ -86,45 +106,41 @@ export function taskCounts(items: readonly TaskItem[]): TaskCounts {
86
106
  }
87
107
 
88
108
  /**
89
- * One-line census: `4 tasks (3 done, 1 in progress, 0 open)`.
109
+ * One-line census: `4 tasks · 3 done · 1 open`.
90
110
  *
91
- * Counts carry the weight and the words recede, so the shape of the batch
92
- * reads before any individual row does. `done`, `in progress`, and `open` are
93
- * always listed even at zero a stable set of three keeps the line from
94
- * reflowing as work moves between them. `blocked` and `dropped` are
95
- * exceptional and appear only when they are not zero.
111
+ * Colour carries the status and the total anchors the line, so nothing needs
112
+ * bold numbers alternating with dim words that zebra was the loudest thing
113
+ * on screen and said the least. Zeros are dropped: "0 in progress" costs a
114
+ * segment to tell you nothing, and a segment appearing when work starts is a
115
+ * signal, not a glitch. When one status covers everything the redundant count
116
+ * collapses to `all`, so a fresh batch reads `8 tasks · all open` rather than
117
+ * `8 tasks · 8 open`.
96
118
  *
97
119
  * Takes counts rather than items because a view often shows a bounded subset
98
120
  * of rows; the header must describe the whole batch regardless.
99
121
  */
100
122
  export function renderTaskSummary(counts: TaskCounts, theme: Theme): string {
101
- const number = (value: number) => theme.bold(theme.fg("text", String(value)));
102
- const dim = (text: string) => theme.fg("dim", text);
103
123
  // Coerced, not trusted: these counts can arrive from a tool-result record
104
124
  // persisted by an older build, where a missing key would render the literal
105
125
  // word "undefined" (or "NaN tasks") into the header.
106
126
  const count = (status: TaskItem["status"]) =>
107
127
  Number.isFinite(counts[status]) ? counts[status] : 0;
108
128
  const total = Number.isFinite(counts.total) ? counts.total : 0;
129
+ if (total <= 0) return theme.fg("dim", "no tasks");
130
+ const present = SUMMARY_ORDER.filter((status) => count(status) > 0);
109
131
  // Built segment by segment rather than by wrapping the whole line: each
110
132
  // styled run emits its own reset, so an outer color would stop applying at
111
133
  // the first inner one.
112
- const parts = SUMMARY_ORDER.filter(
113
- (status) =>
114
- status === "done" ||
115
- status === "in_progress" ||
116
- status === "pending" ||
117
- count(status) > 0,
118
- ).map((status) => `${number(count(status))} ${dim(SUMMARY_LABEL[status])}`);
134
+ const chips = present.map((status) =>
135
+ theme.fg(
136
+ STATUS_COLOR[status],
137
+ `${present.length === 1 && count(status) === total ? "all" : count(status)} ${SUMMARY_LABEL[status]}`,
138
+ ),
139
+ );
119
140
  return [
120
- number(total),
121
- " ",
122
- dim(total === 1 ? "task" : "tasks"),
123
- " ",
124
- dim("("),
125
- parts.join(dim(", ")),
126
- dim(")"),
127
- ].join("");
141
+ theme.fg("dim", `${total} ${total === 1 ? "task" : "tasks"}`),
142
+ ...chips,
143
+ ].join(theme.fg("dim", " · "));
128
144
  }
129
145
 
130
146
  export interface TaskToolDetails {
@@ -150,16 +166,7 @@ export function renderTaskRows(
150
166
  // (all of them) — would otherwise shift every subject sideways by a column.
151
167
  const idWidth = Math.max(...items.map((item) => `T${item.id}`.length), 3);
152
168
  return items.flatMap((item) => {
153
- const color =
154
- item.status === "done"
155
- ? "success"
156
- : item.status === "blocked"
157
- ? "warning"
158
- : item.status === "dropped"
159
- ? "error"
160
- : item.status === "in_progress"
161
- ? "accent"
162
- : "muted";
169
+ const color = STATUS_COLOR[item.status];
163
170
  // No `[status]` text: the icon, its color, and the subject's own weight
164
171
  // already say it, and repeating it in words crowded every row.
165
172
  const id = `T${item.id}`.padStart(idWidth);
@@ -215,17 +222,23 @@ export function renderTaskWidget(
215
222
 
216
223
  const hasOverflow = actionable.length > TASK_WIDGET_LIMIT;
217
224
  const toggleHint = hasOverflow
218
- ? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}`
225
+ ? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}`
219
226
  : "";
220
227
  // Same census as the full list and the /tasks screen. Counted over `tracked`
221
228
  // rather than every item, because the widget deliberately hides dropped work
222
229
  // and a total that included it would not add up against the rows shown.
230
+ //
231
+ // Hints sit on the right edge instead of trailing the census, so the eye lands
232
+ // on state first and the keystrokes stay out of the way until wanted. They are
233
+ // dropped rather than truncated when the terminal is too narrow to hold both.
234
+ const label =
235
+ theme.fg("accent", "◆ ") + theme.fg("text", theme.bold("Tasks"));
236
+ const left = `${label} ${renderTaskSummary(taskCounts(tracked), theme)}`;
237
+ const hint = theme.fg("dim", `/tasks${toggleHint}`);
223
238
  const header =
224
- theme.fg("accent", "◆ ") +
225
- theme.fg("text", theme.bold("Tasks")) +
226
- " " +
227
- renderTaskSummary(taskCounts(tracked), theme) +
228
- theme.fg("dim", ` · /tasks${toggleHint}`);
239
+ visibleWidth(left) + visibleWidth(hint) + 3 <= width
240
+ ? fitNavigationSides(left, hint, width)
241
+ : left;
229
242
  const visible = expanded
230
243
  ? actionable
231
244
  : actionable.slice(0, TASK_WIDGET_LIMIT);
@@ -235,12 +248,7 @@ export function renderTaskWidget(
235
248
  const idWidth = Math.max(...visible.map((i) => `T${i.id}`.length), 3);
236
249
  const lines = [truncateToWidth(header, width)];
237
250
  for (const [index, item] of visible.entries()) {
238
- const color =
239
- item.status === "in_progress"
240
- ? "warning"
241
- : item.status === "blocked"
242
- ? "error"
243
- : "muted";
251
+ const color = STATUS_COLOR[item.status];
244
252
  const branch = index === visible.length - 1 && hidden === 0 ? "╰─" : "├─";
245
253
  lines.push(
246
254
  truncateToWidth(
@@ -379,27 +387,36 @@ class TasksScreen implements Component {
379
387
  }
380
388
 
381
389
  render(width: number) {
382
- const body = renderTaskRows(this.snapshot.items, this.theme, width - 4);
383
- const rows = Math.max(8, (this.tui.terminal.rows || 30) - 8);
390
+ const theme = this.theme;
391
+ const counts = taskCounts(this.snapshot.items);
392
+ const body = renderTaskRows(this.snapshot.items, theme, width - 4);
393
+ // Title (1) + frame (2) + hint (1): one row more chrome than the old bare
394
+ // rule, so the body gives one back and the screen keeps its total height.
395
+ const rows = Math.max(8, (this.tui.terminal.rows || 30) - 9);
384
396
  const maxOffset = Math.max(0, body.length - rows);
385
397
  this.offset = Math.min(this.offset, maxOffset);
386
398
  const visible = body.slice(this.offset, this.offset + rows);
387
- const lines = [
388
- truncateToWidth(
389
- `${this.theme.fg("accent", this.theme.bold("Session tasks"))} ${renderTaskSummary(taskCounts(this.snapshot.items), this.theme)}`,
399
+ // Framed like /subagents, /ps, and /workflows rather than a bare rule: a
400
+ // full-screen view of a list is the same object in each of them, and it
401
+ // should not look like a different control here.
402
+ return [
403
+ screenTitleLine(theme, "Session tasks", "", width),
404
+ ...panelFrame(theme, {
405
+ label: renderTaskSummary(counts, theme),
406
+ rows: visible.map((line) => ` ${line}`),
390
407
  width,
391
- ),
392
- this.theme.fg("border", "─".repeat(Math.max(0, width))),
393
- ...visible.map((line) => truncateToWidth(` ${line}`, width)),
394
- ];
395
- while (lines.length < rows + 2) lines.push("");
396
- lines.push(
397
- truncateToWidth(
398
- this.theme.fg("dim", "j/k or ↑/↓ scroll · pgup/pgdn page · esc close"),
408
+ height: rows + 2,
409
+ }),
410
+ hintLine(
411
+ theme,
412
+ [
413
+ ["j/k or ↑/↓", "scroll"],
414
+ ["pgup/pgdn", "page"],
415
+ ["esc", "close"],
416
+ ],
399
417
  width,
400
418
  ),
401
- );
402
- return lines;
419
+ ];
403
420
  }
404
421
 
405
422
  invalidate() {}
@@ -28,6 +28,9 @@ const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
28
28
 
29
29
  const RESET = "\x1b[0m";
30
30
  const POWERLINE_ARROW = "\ue0b0"; //  — decorative; text remains readable without Nerd Font
31
+ const MODEL_ICON = "\uec10"; // Codicon: sparkle
32
+ const CONTEXT_ICON = "\uebe4"; // Codicon: pie-chart
33
+ const DIRECTORY_ICON = "\uea83"; // Codicon: folder
31
34
 
32
35
  /** Higher = keep longer when the line is too narrow. */
33
36
  const PRIORITY: Record<FooterItem, number> = {
@@ -177,11 +180,11 @@ export function buildSegmentCatalog(
177
180
  : modelInfo.modelId;
178
181
 
179
182
  return {
180
- cwd: { text: formatDirectory(cwd), tone: "text" },
181
- model: { text: modelText, tone: "muted" },
183
+ cwd: { text: `${DIRECTORY_ICON} ${formatDirectory(cwd)}`, tone: "text" },
184
+ model: { text: `${MODEL_ICON} ${modelText}`, tone: "muted" },
182
185
  thinking: { text: modelInfo.thinking, tone: "muted" },
183
186
  context: {
184
- text: contextText,
187
+ text: contextText ? `${CONTEXT_ICON} ${contextText}` : "",
185
188
  tone: contextTone(modelInfo.contextPercent),
186
189
  },
187
190
  cache: {
@@ -199,7 +202,7 @@ export function buildSegmentCatalog(
199
202
  : `~${Math.round(modelInfo.tokensPerSecond)} tok/s`,
200
203
  tone: "muted",
201
204
  },
202
- git: { text: gitInfo.branch ?? "", tone: "muted" },
205
+ git: { text: gitInfo.branch ? `⎇ ${gitInfo.branch}` : "", tone: "muted" },
203
206
  pr: {
204
207
  text: gitInfo.pullRequest
205
208
  ? formatPullRequest(gitInfo.pullRequest.number, gitInfo.pullRequest.url)
@@ -524,43 +527,3 @@ export function renderFooter(options: FooterRenderOptions) {
524
527
 
525
528
  return lines;
526
529
  }
527
-
528
- /** Legacy helper retained for tests that assert grouped content. */
529
- export function buildFooterContent(
530
- modelInfo: ModelInfoState,
531
- gitInfo: GitInfoState,
532
- selectedItems: readonly FooterItem[],
533
- formatPullRequest: (number: number, url: string) => string = (number) =>
534
- `PR #${number}`,
535
- ) {
536
- const items = new Set(selectedItems);
537
- const catalog = buildSegmentCatalog(
538
- "",
539
- modelInfo,
540
- gitInfo,
541
- formatPullRequest,
542
- );
543
- return {
544
- showCwd: items.has("cwd"),
545
- model: [
546
- items.has("model") ? catalog.model.text : "",
547
- items.has("thinking") ? catalog.thinking.text : "",
548
- ]
549
- .filter(Boolean)
550
- .join(" · "),
551
- usage: [
552
- items.has("context") ? catalog.context.text : "",
553
- items.has("cache") ? catalog.cache.text : "",
554
- items.has("cost") ? catalog.cost.text : "",
555
- items.has("throughput") ? catalog.throughput.text : "",
556
- ]
557
- .filter(Boolean)
558
- .join(" · "),
559
- git: [
560
- items.has("git") ? catalog.git.text : "",
561
- items.has("pr") ? catalog.pr.text : "",
562
- ]
563
- .filter(Boolean)
564
- .join(" · "),
565
- };
566
- }
@@ -21,13 +21,9 @@ import {
21
21
  REFRESH_CHANNEL,
22
22
  isGitInfoState,
23
23
  isModelInfoState,
24
- type GitInfoState,
25
- type ModelInfoState,
26
24
  } from "../shared/dashboard-state.ts";
27
25
  import { formatDirectory, renderFooter } from "./footer.ts";
28
26
 
29
- export { buildFooterContent, formatDirectory, renderFooter } from "./footer.ts";
30
-
31
27
  type Rgb = [number, number, number];
32
28
 
33
29
  const RESET = "\x1b[0m";