killeros 2.0.0 → 2.0.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to KillerOS are documented here.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [2.0.2] - 2026-08-08
8
+
9
+ ### Changed
10
+
11
+ - Replaced the static activity Spark with a 12-frame orange glyph loop at 120 ms per frame.
12
+ - Styled activity verbs orange and added the gray `(esc to interrupt · thinking)` status with bold `esc`.
13
+
14
+ ## [2.0.1] - 2026-08-08
15
+
16
+ ### Added
17
+
18
+ - Added a height-bounded question viewport with configured Pi controls, progress labels, and compact expandable history.
19
+ - Added an interactive `/goal` status and actions panel in TUI mode.
20
+ - Added live command-blue highlighting for recognized slash command prefixes in the prompt editor.
21
+
22
+ ### Changed
23
+
24
+ - Made reasoning colors contrast-safe, moved commands and links to the themed command-blue role, and changed activity to a static Spark with shuffled 2.5-second verb updates.
25
+ - Reserved footer goal status for active, paused, and blocked work; completed goals remain in history and `/goal` status.
26
+ - Made startup Git lookup asynchronous, cached footer session cost between content changes, and aligned the browser visual reference with v2.0.1 runtime behavior.
27
+
5
28
  ## [2.0.0] - 2026-08-07
6
29
 
7
30
  ### Changed
package/README.md CHANGED
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
31
31
  Pin an install to a release:
32
32
 
33
33
  ```bash
34
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.0
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.2
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -40,15 +40,15 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
40
40
 
41
41
  - 52-column Compact startup card with inline version, polished model/provider identity, adjacent `/model`, directory, conditional Git branch, and a shuffled session-stable tip
42
42
  - Cohesive dark theme with coral accents and neutral tool-call containers across pending, success, and error states
43
- - Coral Spark activity indicator with Claude-adjacent verbs that advance between agent runs and a quiet hidden-thinking label
44
- - Framed multiline editor with Shift+Enter support
43
+ - Animated orange 12-frame activity glyph loop at 120 ms per frame, with orange shuffled Claude-adjacent verbs changing every 2.5 seconds, a gray `(esc to interrupt · thinking)` status with bold `esc`, and a quiet hidden-thinking label
44
+ - Framed multiline editor with Shift+Enter support and live command-blue highlighting for recognized slash command prefixes
45
45
  - Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
46
46
  - Automatic model-backed context compaction at 40% remaining; active goals continue after the saved summary
47
47
  - `/variants` selector and direct reasoning-level arguments
48
- - Codex-style `/goal` for durable long-running objectives with pause, resume, edit, clear, automatic continuation, and explicit completion
48
+ - Codex-style `/goal` with an interactive status/action panel, durable objectives, pause, resume, edit, confirmed panel clearing, automatic continuation, and explicit completion
49
49
  - Claude Code-style `/init` that scans the repository and generates a concise root `AGENTS.md` without setup questions
50
- - `question` tool with filtering, proposal previews, keyboard selection, custom answers, history, cancellation, and resize-safe rendering
51
- - Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills
50
+ - `question` tool with height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
51
+ - Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
52
52
  - `/clear` for a confirmed new session, plus `/exit` for graceful shutdown
53
53
  - Concise system-prompt guidance without modifying completed assistant messages
54
54
 
@@ -56,7 +56,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
56
56
 
57
57
  ```text
58
58
  /init Generate root AGENTS.md from repository evidence
59
- /goal View the current long-running goal
59
+ /goal Open current goal status and valid actions
60
60
  /goal <objective> Set an objective and start working
61
61
  /goal edit Edit and reactivate the current goal
62
62
  /goal pause Stop automatic continuation
@@ -78,7 +78,7 @@ Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh
78
78
 
79
79
  KillerOS activates its packaged `killeros` theme when a TUI session starts. Tool-call backgrounds stay neutral across pending, successful, and failed states; restrained text and icons preserve status visibility.
80
80
 
81
- KillerOS displays session costs in USD. The footer uses Pi's human-readable model name when available, keeps the provider visually secondary, and renders context as `percent left (tokens)` without a progress bar. When a goal exists, the footer adds its active time or terminal state; at narrow widths, context pressure and goal state take priority.
81
+ KillerOS displays session costs in USD. The footer uses Pi's human-readable model name when available, keeps the provider visually secondary, and renders context as `percent left (tokens)` without a progress bar. Active, paused, and blocked goals remain visible; completed goals remain in transcript history and `/goal` status rather than the footer. At narrow widths, context pressure and actionable goal state take priority.
82
82
 
83
83
  KillerOS checks context after each agent turn. At 40% remaining, it starts Pi's model-backed compaction after the current run settles, so the active turn is not aborted. Manual `/compact` uses the same model path and keeps custom focus instructions. If model compaction is unavailable or exhausts its retries, KillerOS uses the disclosed deterministic fallback and warns that repeated compaction can reduce accuracy.
84
84
 
@@ -0,0 +1,25 @@
1
+ import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
+
3
+ export class BoundedText implements Component {
4
+ private readonly text: string;
5
+ private readonly maxRows?: number;
6
+
7
+ constructor(text: string, maxRows?: number) {
8
+ this.text = text;
9
+ this.maxRows = maxRows;
10
+ }
11
+
12
+ render(width: number): string[] {
13
+ if (width <= 0) return [];
14
+ const lines = new Text(this.text, 0, 0).render(width);
15
+ if (this.maxRows === undefined || lines.length <= this.maxRows) return lines;
16
+ const rowLimit = Math.max(1, this.maxRows);
17
+ const visible = lines.slice(0, rowLimit);
18
+ const suffix = " …";
19
+ const last = visible.at(-1) ?? "";
20
+ visible[rowLimit - 1] = `${truncateToWidth(last, Math.max(0, width - visibleWidth(suffix)), "")}${suffix}`;
21
+ return visible.map((line) => truncateToWidth(line, width, ""));
22
+ }
23
+
24
+ invalidate(): void {}
25
+ }
@@ -54,6 +54,13 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
54
54
  { name: "quit", description: "Quit Pi" },
55
55
  ];
56
56
 
57
+ export function availableCommandNames(pi: ExtensionAPI): ReadonlySet<string> {
58
+ return new Set([
59
+ ...BUILTIN_COMMANDS.map((command) => command.name),
60
+ ...pi.getCommands().map((command) => command.name),
61
+ ]);
62
+ }
63
+
57
64
  const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
58
65
  goal: "/goal [objective|clear|edit|pause|resume]",
59
66
  variants: "/variants [level]",
@@ -116,16 +116,34 @@ function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
116
116
  if (state.status === "active") return theme.fg("accent", `✻ goal · ${formatTime(goalElapsedMilliseconds(state))}`);
117
117
  if (state.status === "paused") return theme.fg("warning", "Ⅱ goal paused");
118
118
  if (state.status === "blocked") return theme.fg("error", "! goal blocked");
119
- return theme.fg("success", "✓ goal complete");
119
+ return "";
120
120
  }
121
121
 
122
122
  export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
123
123
  let currentModel: ExtensionContext["model"];
124
124
  let thinkingLevel: ThinkingLevel = "off";
125
125
  let activeTui: TUI | undefined;
126
+ let cachedSessionCost = 0;
127
+ let sessionCostDirty = true;
128
+ const resetSessionCost = (): void => {
129
+ cachedSessionCost = 0;
130
+ sessionCostDirty = true;
131
+ };
132
+ const invalidateSessionCost = (): void => {
133
+ sessionCostDirty = true;
134
+ activeTui?.requestRender();
135
+ };
136
+ const getSessionCost = (ctx: ExtensionContext): number => {
137
+ if (sessionCostDirty) {
138
+ cachedSessionCost = sumSessionCost(ctx);
139
+ sessionCostDirty = false;
140
+ }
141
+ return cachedSessionCost;
142
+ };
126
143
  goalRuntime.requestRender = () => activeTui?.requestRender();
127
144
 
128
145
  pi.on("session_start", (_event, ctx) => {
146
+ resetSessionCost();
129
147
  if (ctx.mode !== "tui") return;
130
148
  const sessionStart = Date.now();
131
149
  currentModel = ctx.model;
@@ -170,7 +188,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
170
188
  goal,
171
189
  branch ? theme.fg("dim", branch) : "",
172
190
  theme.fg("dim", formatTime(Date.now() - sessionStart)),
173
- theme.fg("dim", formatCost(sumSessionCost(ctx))),
191
+ theme.fg("dim", formatCost(getSessionCost(ctx))),
174
192
  ], theme);
175
193
  const focused = joinFooterParts([signature, context, goal], theme);
176
194
 
@@ -207,7 +225,14 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
207
225
  thinkingLevel = event.level;
208
226
  activeTui?.requestRender();
209
227
  });
228
+ pi.on("turn_end", invalidateSessionCost);
229
+ pi.on("session_compact", invalidateSessionCost);
230
+ pi.on("session_tree", () => {
231
+ resetSessionCost();
232
+ activeTui?.requestRender();
233
+ });
210
234
  pi.on("session_shutdown", () => {
235
+ resetSessionCost();
211
236
  activeTui = undefined;
212
237
  goalRuntime.requestRender = undefined;
213
238
  });
package/killeros/goals.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { MAX_NODE_TIMER_MS } from "./limits.ts";
5
+ import { BoundedText } from "./bounded-text.ts";
5
6
  import { CONCISE_SYSTEM_PROMPT } from "./concise.ts";
6
7
  import { formatTime, formatTokens } from "./display.ts";
7
8
  import { reportError } from "./errors.ts";
@@ -175,6 +176,18 @@ function goalStatusLabel(status: GoalStatus): string {
175
176
  return `${status.charAt(0).toLocaleUpperCase()}${status.slice(1)}`;
176
177
  }
177
178
 
179
+ function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "pause" | "resume" | "edit" | "clear" }> {
180
+ const terminal = [
181
+ { label: "Edit objective", control: "edit" as const },
182
+ { label: "Clear goal", control: "clear" as const },
183
+ ];
184
+ if (status === "active") return [{ label: "Pause automatic continuation", control: "pause" }, ...terminal];
185
+ if (status === "paused" || status === "blocked") {
186
+ return [{ label: "Resume automatic continuation", control: "resume" }, ...terminal];
187
+ }
188
+ return terminal;
189
+ }
190
+
178
191
  function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
179
192
  const usedTokens = Math.max(0, sumGoalTokens(ctx) - state.baselineTokens);
180
193
  const lines = [
@@ -293,7 +306,7 @@ export function registerGoal(
293
306
  runtime: GoalRuntime,
294
307
  initState: InitRuntime,
295
308
  ): void {
296
- pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, _options, theme) => {
309
+ pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
297
310
  const data = entry.data;
298
311
  if (!data || data.version !== GOAL_VERSION || data.event === "turn" || data.event === "checkpoint") return undefined;
299
312
  if (data.event === "clear" || data.state === null) return new Text(theme.fg("dim", "Goal cleared"), 0, 0);
@@ -301,7 +314,11 @@ export function registerGoal(
301
314
  if (!state) return undefined;
302
315
  const icon = state.status === "active" ? "✻" : state.status === "paused" ? "Ⅱ" : state.status === "blocked" ? "!" : "✓";
303
316
  const color: ThemeColor = state.status === "active" ? "accent" : state.status === "paused" ? "warning" : state.status === "blocked" ? "error" : "success";
304
- return new Text(`${theme.fg(color, `${icon} Goal ${state.status}`)}${theme.fg("dim", ` · ${state.objective}`)}`, 0, 0);
317
+ const status = theme.fg(color, `${icon} Goal ${state.status}`);
318
+ if (!options.expanded) return new BoundedText(`${status}${theme.fg("dim", ` · ${state.objective}`)}`, 3);
319
+ const lines = [status, theme.fg("dim", state.objective)];
320
+ if (state.result) lines.push(theme.fg("muted", state.result));
321
+ return new BoundedText(lines.join("\n"));
305
322
  });
306
323
 
307
324
  pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
@@ -329,11 +346,11 @@ export function registerGoal(
329
346
  renderCall(args, theme) {
330
347
  return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
331
348
  },
332
- renderResult(result, _options, theme) {
349
+ renderResult(result, options, theme) {
333
350
  const details = result.details;
334
- return new Text(details
335
- ? `${theme.fg(details.status === "complete" ? "success" : "warning", details.status === "complete" ? "✓ Complete" : "! Blocked")}${theme.fg("dim", ` · ${details.evidence}`)}`
336
- : theme.fg("dim", "Goal updated"), 0, 0);
351
+ if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
352
+ const text = `${theme.fg(details.status === "complete" ? "success" : "warning", details.status === "complete" ? "✓ Complete" : "! Blocked")}${theme.fg("dim", ` · ${details.evidence}`)}`;
353
+ return new BoundedText(text, options.expanded ? undefined : 3);
337
354
  },
338
355
  });
339
356
 
@@ -428,22 +445,7 @@ export function registerGoal(
428
445
  runtime.lastError = finalAssistant?.errorMessage;
429
446
  });
430
447
 
431
- pi.registerCommand("goal", {
432
- description: "Set or view the goal for a long-running task",
433
- getArgumentCompletions: (prefix) => {
434
- const normalized = prefix.trimStart().toLocaleLowerCase();
435
- if (normalized.includes(" ")) return null;
436
- const actions = [
437
- { value: "clear", description: "Remove the current goal" },
438
- { value: "edit", description: "Edit and reactivate the current goal" },
439
- { value: "pause", description: "Stop automatic continuation" },
440
- { value: "resume", description: "Resume automatic continuation" },
441
- ];
442
- return actions
443
- .filter((action) => action.value.startsWith(normalized))
444
- .map((action) => ({ ...action, label: action.value }));
445
- },
446
- handler: async (args, ctx) => {
448
+ const handleGoalCommand = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
447
449
  if (ctx.mode === "print" || ctx.mode === "json") {
448
450
  ctx.ui.notify("/goal requires TUI or RPC mode", "error");
449
451
  return;
@@ -461,7 +463,16 @@ export function registerGoal(
461
463
  ctx.ui.notify("No goal is set. Use /goal <objective> to start a long-running task.", "info");
462
464
  return;
463
465
  }
464
- ctx.ui.notify(goalStatusSummary(runtime.state, ctx), "info");
466
+ if (ctx.mode !== "tui") {
467
+ ctx.ui.notify(goalStatusSummary(runtime.state, ctx), "info");
468
+ return;
469
+ }
470
+ const actions = goalPanelActions(runtime.state.status);
471
+ const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
472
+ const action = actions.find((candidate) => candidate.label === selected);
473
+ if (!action) return;
474
+ if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", runtime.state.objective)) return;
475
+ await handleGoalCommand(action.control, ctx);
465
476
  return;
466
477
  }
467
478
 
@@ -684,7 +695,24 @@ export function registerGoal(
684
695
  reportError(ctx, "Goal could not be started", error);
685
696
  scheduleGoalContinuation(pi, runtime, initState, ctx);
686
697
  }
698
+ };
699
+
700
+ pi.registerCommand("goal", {
701
+ description: "Set or view the goal for a long-running task",
702
+ getArgumentCompletions: (prefix) => {
703
+ const normalized = prefix.trimStart().toLocaleLowerCase();
704
+ if (normalized.includes(" ")) return null;
705
+ const actions = [
706
+ { value: "clear", description: "Remove the current goal" },
707
+ { value: "edit", description: "Edit and reactivate the current goal" },
708
+ { value: "pause", description: "Stop automatic continuation" },
709
+ { value: "resume", description: "Resume automatic continuation" },
710
+ ];
711
+ return actions
712
+ .filter((action) => action.value.startsWith(normalized))
713
+ .map((action) => ({ ...action, label: action.value }));
687
714
  },
715
+ handler: handleGoalCommand,
688
716
  });
689
717
  }
690
718
 
@@ -1,19 +1,14 @@
1
- import { type ExtensionAPI, type ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import { keyHint, type ExtensionAPI, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
- Container,
4
3
  decodeKittyPrintable,
5
4
  Editor,
6
- Key,
7
5
  Markdown,
8
- matchesKey,
9
- SelectList,
10
- Text,
11
6
  truncateToWidth,
12
- visibleWidth,
13
7
  wrapTextWithAnsi,
14
8
  type EditorTheme,
15
9
  } from "@earendil-works/pi-tui";
16
10
  import { Type } from "typebox";
11
+ import { BoundedText } from "./bounded-text.ts";
17
12
 
18
13
  const OptionSchema = Type.Object({
19
14
  label: Type.String({ minLength: 1, maxLength: 200, description: "Display label for the option" }),
@@ -90,6 +85,16 @@ function removeLastGrapheme(value: string): string {
90
85
  return last ? value.slice(0, last.index) : "";
91
86
  }
92
87
 
88
+ function oneLine(value: string): string {
89
+ return value.replace(/\s+/gu, " ").trim();
90
+ }
91
+
92
+ function visibleOptionRange(total: number, selected: number, capacity: number): { start: number; end: number } {
93
+ const size = Math.max(1, Math.min(total, capacity));
94
+ const start = Math.max(0, Math.min(selected - Math.floor(size / 2), total - size));
95
+ return { start, end: Math.min(total, start + size) };
96
+ }
97
+
93
98
  export function registerQuestionTool(pi: ExtensionAPI): void {
94
99
  const customInputHistory: string[] = [];
95
100
  let customInputHistoryBytes = 0;
@@ -153,11 +158,12 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
153
158
  ];
154
159
 
155
160
  let finishFromAbort: (() => void) | undefined;
156
- const resultPromise = ctx.ui.custom<QuestionSelection>((tui, theme, _keybindings, done) => {
161
+ const resultPromise = ctx.ui.custom<QuestionSelection>((tui, theme, keybindings, done) => {
157
162
  let optionIndex = 0;
158
163
  let editMode = false;
159
164
  let filterQuery = "";
160
165
  let cachedWidth: number | undefined;
166
+ let cachedRows: number | undefined;
161
167
  let cachedLines: string[] | undefined;
162
168
  let completed = false;
163
169
 
@@ -191,6 +197,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
191
197
 
192
198
  const invalidate = (): void => {
193
199
  cachedWidth = undefined;
200
+ cachedRows = undefined;
194
201
  cachedLines = undefined;
195
202
  editor.invalidate();
196
203
  };
@@ -241,7 +248,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
241
248
 
242
249
  const handleInput = (data: string): void => {
243
250
  if (editMode) {
244
- if (matchesKey(data, Key.escape)) {
251
+ if (keybindings.matches(data, "tui.select.cancel")) {
245
252
  editMode = false;
246
253
  editor.setText("");
247
254
  refresh();
@@ -260,24 +267,35 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
260
267
 
261
268
  const visibleOptions = filteredOptions();
262
269
  if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
263
- if (matchesKey(data, Key.up)) {
270
+ const pageSize = Math.max(1, Math.min(5, Math.ceil(Math.max(1, tui.terminal.rows - 5) / 2)));
271
+ if (keybindings.matches(data, "tui.select.up")) {
264
272
  optionIndex = Math.max(0, optionIndex - 1);
265
273
  refresh();
266
274
  return;
267
275
  }
268
- if (matchesKey(data, Key.down)) {
276
+ if (keybindings.matches(data, "tui.select.down")) {
269
277
  optionIndex = Math.min(visibleOptions.length - 1, optionIndex + 1);
270
278
  refresh();
271
279
  return;
272
280
  }
273
- if (matchesKey(data, Key.enter)) {
281
+ if (keybindings.matches(data, "tui.select.pageUp")) {
282
+ optionIndex = Math.max(0, optionIndex - pageSize);
283
+ refresh();
284
+ return;
285
+ }
286
+ if (keybindings.matches(data, "tui.select.pageDown")) {
287
+ optionIndex = Math.min(visibleOptions.length - 1, optionIndex + pageSize);
288
+ refresh();
289
+ return;
290
+ }
291
+ if (keybindings.matches(data, "tui.select.confirm")) {
274
292
  const selected = visibleOptions[optionIndex];
275
293
  if (!selected) return;
276
294
  if (selected.isOther) enterCustomMode();
277
295
  else finish({ kind: "selected", answer: selected.label, originalIndex: selected.originalIndex });
278
296
  return;
279
297
  }
280
- if (matchesKey(data, Key.escape)) {
298
+ if (keybindings.matches(data, "tui.select.cancel")) {
281
299
  if (filterQuery) {
282
300
  filterQuery = "";
283
301
  optionIndex = 0;
@@ -287,7 +305,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
287
305
  }
288
306
  return;
289
307
  }
290
- if (matchesKey(data, Key.backspace)) {
308
+ if (keybindings.matches(data, "tui.editor.deleteCharBackward")) {
291
309
  if (filterQuery) {
292
310
  filterQuery = removeLastGrapheme(filterQuery);
293
311
  optionIndex = 0;
@@ -304,60 +322,94 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
304
322
  else finish({ kind: "selected", answer: selected.label, originalIndex: selected.originalIndex });
305
323
  return;
306
324
  }
307
- if (printableInput) {
308
- appendFilterInput(printableInput);
309
- }
325
+ if (printableInput) appendFilterInput(printableInput);
310
326
  };
311
327
 
312
328
  const render = (width: number): string[] => {
313
- const renderWidth = Math.max(1, width);
314
- if (cachedLines && cachedWidth === renderWidth) return cachedLines;
315
- const lines: string[] = [];
316
- const addWrapped = (text: string): void => {
317
- lines.push(...wrapTextWithAnsi(text, renderWidth));
318
- };
319
- const addWrappedWithPrefix = (prefix: string, text: string): void => {
320
- const prefixWidth = visibleWidth(prefix);
321
- if (prefixWidth >= renderWidth) {
322
- addWrapped(prefix + text);
323
- return;
324
- }
325
- const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
326
- const continuation = " ".repeat(prefixWidth);
327
- wrapped.forEach((line, index) => lines.push(`${index === 0 ? prefix : continuation}${line}`));
328
- };
329
+ if (width <= 0) return [];
330
+ const renderWidth = width;
331
+ const rowBudget = Math.max(1, tui.terminal.rows);
332
+ if (cachedLines && cachedWidth === renderWidth && cachedRows === rowBudget) return cachedLines;
333
+ const visibleOptions = filteredOptions();
334
+ if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
335
+ const selected = visibleOptions[optionIndex];
336
+ const position = `Option ${Math.min(optionIndex + 1, visibleOptions.length)}/${visibleOptions.length}`;
337
+ const answerCount = inputCharacterCount(editor.getExpandedText()).toLocaleString();
338
+ const filterCount = inputCharacterCount(filterQuery).toLocaleString();
339
+
340
+ if (rowBudget <= 2) {
341
+ const compact = editMode
342
+ ? [`Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, editor.getExpandedText() || "Type an answer"]
343
+ : [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
344
+ cachedWidth = renderWidth;
345
+ cachedRows = rowBudget;
346
+ cachedLines = compact.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, "…"));
347
+ return cachedLines;
348
+ }
329
349
 
330
- lines.push(theme.fg("accent", "─".repeat(renderWidth)));
331
- addWrappedWithPrefix(" ", theme.fg("text", params.question));
332
- lines.push("");
333
- if (!editMode && filterQuery) {
334
- addWrappedWithPrefix(" ", `${theme.fg("muted", "Filter: ")}${theme.fg("accent", filterQuery)}`);
335
- lines.push("");
350
+ if (rowBudget <= 5) {
351
+ const compact = [
352
+ truncateToWidth(params.question.replace(/\s+/gu, " "), renderWidth, "…"),
353
+ editMode
354
+ ? `Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
355
+ : `${selected ? `> ${selected.label}` : "No matching options"} · ${position}`,
356
+ editMode
357
+ ? editor.getExpandedText() || "Type an answer"
358
+ : filterQuery
359
+ ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}`
360
+ : position,
361
+ editMode
362
+ ? `${keyHint("tui.input.submit", "submit")} • ${keyHint("tui.select.cancel", "options")}`
363
+ : `${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`,
364
+ ];
365
+ cachedWidth = renderWidth;
366
+ cachedRows = rowBudget;
367
+ cachedLines = compact.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, "…"));
368
+ return cachedLines;
336
369
  }
337
370
 
338
- const visibleOptions = filteredOptions();
339
- if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
340
- visibleOptions.forEach((option, index) => {
341
- const selected = index === optionIndex;
342
- const prefix = selected ? theme.fg("accent", "> ") : " ";
343
- const color: ThemeColor = selected ? "accent" : "text";
344
- addWrappedWithPrefix(prefix, theme.fg(color, `${index + 1}. ${option.label}`));
345
- if (selected && option.description) {
346
- addWrappedWithPrefix(" ", theme.fg("muted", option.description));
371
+ const contentRows = rowBudget - 5;
372
+ const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
373
+ const detailCapacity = Math.max(0, contentRows - optionCapacity);
374
+ const { start, end } = visibleOptionRange(visibleOptions.length, optionIndex, optionCapacity);
375
+ const hiddenAbove = start > 0 ? `↑ ${start}` : "";
376
+ const hiddenBelowCount = visibleOptions.length - end;
377
+ const hiddenBelow = hiddenBelowCount > 0 ? `↓ ${hiddenBelowCount}` : "";
378
+ const hiddenStatus = [hiddenAbove, hiddenBelow].filter(Boolean).join(" · ");
379
+ const progress = editMode
380
+ ? `Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
381
+ : filterQuery
382
+ ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} · ${position}`
383
+ : `${position}${hiddenStatus ? ` · ${hiddenStatus}` : ""}`;
384
+ const hint = editMode
385
+ ? `${keyHint("tui.input.submit", "submit")} • ${keyHint("tui.select.cancel", "options")}`
386
+ : `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`;
387
+ const lines: string[] = [
388
+ theme.fg("accent", "─".repeat(renderWidth)),
389
+ theme.fg("text", truncateToWidth(` ${params.question.replace(/\s+/gu, " ")}`, renderWidth, "…")),
390
+ theme.fg("muted", truncateToWidth(` ${progress}`, renderWidth, "…")),
391
+ ];
392
+
393
+ if (editMode) {
394
+ const editorLines = editor.render(renderWidth);
395
+ const draftLines = editorLines.length > 2 ? editorLines.slice(1, -1) : editorLines;
396
+ lines.push(...(draftLines.length > 0 ? draftLines : ["Type an answer"]).slice(-contentRows));
397
+ } else {
398
+ for (let index = start; index < end; index += 1) {
399
+ const option = visibleOptions[index]!;
400
+ const isSelected = index === optionIndex;
401
+ const prefix = isSelected ? "> " : " ";
402
+ const color: ThemeColor = isSelected ? "accent" : "text";
403
+ lines.push(theme.fg(color, truncateToWidth(`${prefix}${index + 1}. ${option.label}`, renderWidth, "…")));
347
404
  }
348
- });
349
-
350
- const selectedPreview = visibleOptions[optionIndex]?.preview;
351
- if (!editMode && selectedPreview) {
352
- const footerRows = 3;
353
- const previewChromeRows = 2;
354
- const availableRows = tui.terminal.rows - lines.length - footerRows;
355
- if (availableRows > previewChromeRows) {
356
- lines.push("");
357
- addWrappedWithPrefix(" ", theme.fg("accent", theme.bold("Proposal preview")));
358
- const markdownLines = new Markdown(
359
- selectedPreview,
360
- 1,
405
+
406
+ const detailLines: string[] = [];
407
+ if (selected?.description) detailLines.push(...wrapTextWithAnsi(theme.fg("muted", selected.description), renderWidth));
408
+ if (selected?.preview) {
409
+ detailLines.push(theme.fg("accent", theme.bold("Proposal preview")));
410
+ detailLines.push(...new Markdown(
411
+ selected.preview,
412
+ 0,
361
413
  0,
362
414
  {
363
415
  heading: (text) => theme.fg("accent", theme.bold(text)),
@@ -376,35 +428,23 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
376
428
  underline: (text) => theme.underline(text),
377
429
  },
378
430
  { color: (text) => theme.fg("muted", text) },
379
- ).render(renderWidth);
380
- const maxPreviewRows = Math.min(12, availableRows - previewChromeRows);
381
- if (markdownLines.length <= maxPreviewRows) {
382
- lines.push(...markdownLines);
383
- } else {
384
- const visiblePreviewRows = Math.max(0, maxPreviewRows - 1);
385
- lines.push(...markdownLines.slice(0, visiblePreviewRows));
386
- const hiddenRows = markdownLines.length - visiblePreviewRows;
387
- lines.push(theme.fg("dim", ` … ${hiddenRows} more line${hiddenRows === 1 ? "" : "s"}`));
388
- }
431
+ ).render(renderWidth));
432
+ }
433
+ if (detailCapacity > 0 && detailLines.length > detailCapacity) {
434
+ const visibleDetailRows = Math.max(0, detailCapacity - 1);
435
+ lines.push(...detailLines.slice(0, visibleDetailRows));
436
+ const hiddenRows = detailLines.length - visibleDetailRows;
437
+ lines.push(theme.fg("dim", `… ${hiddenRows} more line${hiddenRows === 1 ? "" : "s"}`));
438
+ } else {
439
+ lines.push(...detailLines.slice(0, detailCapacity));
389
440
  }
390
441
  }
391
442
 
392
- if (editMode) {
393
- lines.push("");
394
- addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
395
- editor.render(Math.max(1, renderWidth - 2)).forEach((line) => lines.push(` ${line}`));
396
- }
397
-
398
- lines.push("");
399
- const hint = editMode
400
- ? `Enter submit • Esc options${customInputHistory.length ? " • ↑↓ history" : ""}`
401
- : filterQuery
402
- ? "1-9 select • ↑↓ navigate • Enter select • Esc clear filter"
403
- : "1-9 select • type to filter • ↑↓ navigate • Enter select • Esc cancel";
404
- addWrappedWithPrefix(" ", theme.fg("dim", hint));
443
+ lines.push(theme.fg("dim", truncateToWidth(` ${hint}`, renderWidth, "…")));
405
444
  lines.push(theme.fg("accent", "─".repeat(renderWidth)));
406
445
  cachedWidth = renderWidth;
407
- cachedLines = lines.map((line) => truncateToWidth(line, renderWidth, ""));
446
+ cachedRows = rowBudget;
447
+ cachedLines = lines.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, ""));
408
448
  return cachedLines;
409
449
  };
410
450
 
@@ -457,27 +497,34 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
457
497
  };
458
498
  },
459
499
 
460
- renderCall(args, theme) {
461
- let text = `${theme.fg("toolTitle", theme.bold("question "))}${theme.fg("muted", args.question)}`;
462
- if (args.options.length) {
463
- const numbered = [...args.options.map((option) => option.label), "Type a custom answer"]
464
- .map((option, index) => `${index + 1}. ${option}`);
465
- text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
500
+ renderCall(args, theme, context) {
501
+ if (!context.expanded) {
502
+ const summary = `${theme.fg("toolTitle", theme.bold("question "))}${theme.fg("muted", oneLine(args.question))}\n${theme.fg("dim", ` ${args.options.length} option${args.options.length === 1 ? "" : "s"}`)}`;
503
+ return new BoundedText(summary, 3);
466
504
  }
467
- return new Text(text, 0, 0);
505
+
506
+ const lines = [`${theme.fg("toolTitle", theme.bold("question "))}${theme.fg("muted", args.question)}`];
507
+ args.options.forEach((option, index) => {
508
+ lines.push(theme.fg("text", `${index + 1}. ${option.label}`));
509
+ if (option.description) lines.push(theme.fg("muted", ` ${option.description}`));
510
+ if (option.preview) lines.push(theme.fg("dim", option.preview));
511
+ });
512
+ lines.push(theme.fg("text", `${args.options.length + 1}. Type a custom answer`));
513
+ return new BoundedText(lines.join("\n"));
468
514
  },
469
515
 
470
- renderResult(result, _options, theme) {
516
+ renderResult(result, options, theme) {
471
517
  const details = result.details;
472
518
  if (!details) {
473
519
  const first = result.content[0];
474
- return new Text(first?.type === "text" ? first.text : "", 0, 0);
520
+ return new BoundedText(first?.type === "text" ? first.text : "", options.expanded ? undefined : 3);
475
521
  }
476
- if (details.cancelled || details.answer === null) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
522
+ if (details.cancelled || details.answer === null) return new BoundedText(theme.fg("warning", "Cancelled"));
477
523
  if (details.wasCustom) {
478
- return new Text(`${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", details.answer)}`, 0, 0);
524
+ const text = `${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", details.answer)}`;
525
+ return new BoundedText(text, options.expanded ? undefined : 3);
479
526
  }
480
- return new Text(`${theme.fg("success", "✓ ")}${theme.fg("accent", details.answer)}`, 0, 0);
527
+ return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", details.answer)}`);
481
528
  },
482
529
  });
483
530
  }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import {
4
4
  CustomEditor,
@@ -11,6 +11,7 @@ import {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import {
13
13
  Container,
14
+ CURSOR_MARKER,
14
15
  Text,
15
16
  truncateToWidth,
16
17
  visibleWidth,
@@ -18,16 +19,14 @@ import {
18
19
  type EditorTheme,
19
20
  type TUI,
20
21
  } from "@earendil-works/pi-tui";
22
+ import { availableCommandNames } from "./commands.ts";
21
23
  import { formatCwd, padRight } from "./display.ts";
22
24
  import { reportError } from "./errors.ts";
23
25
  import { formatModel } from "./footer.ts";
24
26
  import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
25
27
 
26
- const COMMAND_BLUE_RGB = "120;169;255";
27
28
  const COMPACT_HEADER_MAX_WIDTH = 52;
28
29
 
29
- const commandBlue = (text: string): string => `\x1B[38;2;${COMMAND_BLUE_RGB}m${text}\x1B[39m`;
30
-
31
30
  function readPackageVersion(path: string | URL): string | undefined {
32
31
  try {
33
32
  const value = JSON.parse(readFileSync(path, "utf8")) as { version?: unknown };
@@ -45,20 +44,27 @@ const STARTUP_TIPS = [
45
44
  "Type / to browse every command available in this session.",
46
45
  ] as const;
47
46
 
48
- function resolveGitBranch(cwd: string): string | undefined {
49
- try {
50
- const branch = execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
51
- encoding: "utf8",
52
- maxBuffer: 64 * 1024,
53
- stdio: ["ignore", "pipe", "ignore"],
54
- timeout: 500,
55
- windowsHide: true,
56
- }).trim();
57
- if (!branch) return undefined;
58
- return branch === "HEAD" ? "detached" : branch;
59
- } catch {
60
- return undefined;
61
- }
47
+ export function resolveGitBranch(cwd: string): Promise<string | undefined> {
48
+ return new Promise((resolve) => {
49
+ execFile(
50
+ "git",
51
+ ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
52
+ {
53
+ encoding: "utf8",
54
+ maxBuffer: 64 * 1024,
55
+ timeout: 500,
56
+ windowsHide: true,
57
+ },
58
+ (error, stdout) => {
59
+ if (error) {
60
+ resolve(undefined);
61
+ return;
62
+ }
63
+ const branch = stdout.trim();
64
+ resolve(branch ? branch === "HEAD" ? "detached" : branch : undefined);
65
+ },
66
+ );
67
+ });
62
68
  }
63
69
 
64
70
  function shuffledTips(): string[] {
@@ -78,14 +84,21 @@ function compactBoxLine(content: string, width: number, theme: Theme): string {
78
84
  class PiStartupHeader {
79
85
  private readonly pi: ExtensionAPI;
80
86
  private readonly ctx: ExtensionContext;
81
- private readonly branch: string | undefined;
82
87
  private readonly tip: string;
88
+ private readonly tui: TUI;
89
+ private branch: string | undefined;
90
+ private disposed = false;
83
91
 
84
- constructor(pi: ExtensionAPI, ctx: ExtensionContext, tip: string) {
92
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, tip: string, tui: TUI) {
85
93
  this.pi = pi;
86
94
  this.ctx = ctx;
87
- this.branch = resolveGitBranch(ctx.cwd);
88
95
  this.tip = tip;
96
+ this.tui = tui;
97
+ void resolveGitBranch(ctx.cwd).then((branch) => {
98
+ if (this.disposed) return;
99
+ this.branch = branch;
100
+ this.tui.requestRender();
101
+ });
89
102
  }
90
103
 
91
104
  private tipLines(width: number, theme: Theme): string[] {
@@ -113,7 +126,7 @@ class PiStartupHeader {
113
126
  const repository = this.branch
114
127
  ? `${directory} ${theme.fg("dim", `· ${this.branch}`)}`
115
128
  : directory;
116
- const modelCommand = commandBlue("/model");
129
+ const modelCommand = theme.fg("mdLink", "/model");
117
130
  const agentWidth = Math.max(0, innerWidth - visibleWidth(modelCommand) - 1);
118
131
  const agentCommand = `${truncateToWidth(agent, agentWidth, "…")} ${modelCommand}`;
119
132
  const border = (left: string, right: string): string => theme.fg("dim", `${left}${"─".repeat(panelWidth - 2)}${right}`);
@@ -131,10 +144,119 @@ class PiStartupHeader {
131
144
  }
132
145
 
133
146
  invalidate(): void {}
134
- dispose(): void {}
147
+ dispose(): void {
148
+ this.disposed = true;
149
+ }
135
150
  }
136
151
 
137
152
  const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
153
+ const ANSI_SEQUENCE_AT_START = /^\x1b\[[0-?]*[ -/]*[@-~]/u;
154
+ const COMMAND_TOKEN_PATTERN = /(^|[ \t])(\/[A-Za-z0-9:_-]*)/gu;
155
+
156
+ function controlSequenceAt(text: string, index: number): string | undefined {
157
+ if (text.startsWith(CURSOR_MARKER, index)) return CURSOR_MARKER;
158
+ return text.slice(index).match(ANSI_SEQUENCE_AT_START)?.[0];
159
+ }
160
+
161
+ interface CommandToken {
162
+ text: string;
163
+ start: number;
164
+ end: number;
165
+ valid: boolean;
166
+ }
167
+
168
+ interface EditorVisualLine {
169
+ logicalLine: number;
170
+ startCol: number;
171
+ length: number;
172
+ }
173
+
174
+ function commandTokens(text: string, normalizedNames: readonly string[]): CommandToken[] {
175
+ return [...text.matchAll(COMMAND_TOKEN_PATTERN)].map((match) => {
176
+ const token = match[2] ?? "";
177
+ const prefix = token.slice(1).toLocaleLowerCase();
178
+ const start = (match.index ?? 0) + (match[1]?.length ?? 0);
179
+ return {
180
+ text: token,
181
+ start,
182
+ end: start + token.length,
183
+ valid: normalizedNames.some((name) => name.startsWith(prefix)),
184
+ };
185
+ });
186
+ }
187
+
188
+ function highlightTextRanges(
189
+ text: string,
190
+ ranges: Array<{ start: number; end: number }>,
191
+ color: (value: string) => string,
192
+ ): string {
193
+ if (ranges.length === 0) return text;
194
+
195
+ let output = "";
196
+ let buffer = "";
197
+ let bufferHighlighted: boolean | undefined;
198
+ let plainIndex = 0;
199
+ const flush = (): void => {
200
+ if (!buffer) return;
201
+ output += bufferHighlighted ? color(buffer) : buffer;
202
+ buffer = "";
203
+ };
204
+
205
+ for (let index = 0; index < text.length;) {
206
+ const control = controlSequenceAt(text, index);
207
+ if (control) {
208
+ flush();
209
+ output += control;
210
+ index += control.length;
211
+ continue;
212
+ }
213
+
214
+ const highlighted = ranges.some((range) => plainIndex >= range.start && plainIndex < range.end);
215
+ if (bufferHighlighted !== highlighted) {
216
+ flush();
217
+ bufferHighlighted = highlighted;
218
+ }
219
+ buffer += text[index];
220
+ plainIndex += 1;
221
+ index += 1;
222
+ }
223
+ flush();
224
+ return output;
225
+ }
226
+
227
+ function highlightEditorLines(
228
+ lines: string[],
229
+ sourceLines: string[],
230
+ visualLines: EditorVisualLine[],
231
+ scrollOffset: number,
232
+ commandNames: ReadonlySet<string>,
233
+ color: (value: string) => string,
234
+ ): { lines: string[]; bottomBorderIndex: number } {
235
+ let bottomBorderIndex = -1;
236
+ for (let index = lines.length - 1; index >= 1; index -= 1) {
237
+ if (isBorderLine(lines[index] ?? "")) {
238
+ bottomBorderIndex = index;
239
+ break;
240
+ }
241
+ }
242
+ if (bottomBorderIndex < 0) bottomBorderIndex = lines.length - 1;
243
+
244
+ const normalizedNames = [...commandNames].map((name) => name.toLocaleLowerCase());
245
+ for (let index = 1; index < bottomBorderIndex; index += 1) {
246
+ const visualLine = visualLines[scrollOffset + index - 1];
247
+ if (!visualLine) continue;
248
+ const visibleStart = visualLine.startCol;
249
+ const visibleEnd = visibleStart + visualLine.length;
250
+ const ranges = commandTokens(sourceLines[visualLine.logicalLine] ?? "", normalizedNames)
251
+ .filter((token) => token.valid && token.start < visibleEnd && token.end > visibleStart)
252
+ .map((token) => ({
253
+ start: Math.max(token.start, visibleStart) - visibleStart,
254
+ end: Math.min(token.end, visibleEnd) - visibleStart,
255
+ }));
256
+ lines[index] = highlightTextRanges(lines[index] ?? "", ranges, color);
257
+ }
258
+ return { lines, bottomBorderIndex };
259
+ }
138
260
 
139
261
  function stripAnsi(text: string): string {
140
262
  return text.replace(ANSI_REGEX, "").trim();
@@ -145,12 +267,27 @@ function isBorderLine(line: string): boolean {
145
267
  return /^[─━═]+$/.test(unstyled) || /^───\s*[↓↑]/.test(unstyled) || /^─{3,}/.test(unstyled);
146
268
  }
147
269
 
270
+ function isScrolledTopBorder(line: string): boolean {
271
+ const unstyled = stripAnsi(line);
272
+ return unstyled.includes("↑") || unstyled.includes(".");
273
+ }
274
+
148
275
  class PiCodeEditor extends CustomEditor {
149
276
  private readonly appKeybindings: KeybindingsManager;
150
-
151
- constructor(tui: TUI, theme: EditorTheme, appKeybindings: KeybindingsManager) {
277
+ private readonly runtimeTheme: Theme;
278
+ private readonly getCommandNames: () => ReadonlySet<string>;
279
+
280
+ constructor(
281
+ tui: TUI,
282
+ theme: EditorTheme,
283
+ appKeybindings: KeybindingsManager,
284
+ runtimeTheme: Theme,
285
+ getCommandNames: () => ReadonlySet<string>,
286
+ ) {
152
287
  super(tui, theme, appKeybindings);
153
288
  this.appKeybindings = appKeybindings;
289
+ this.runtimeTheme = runtimeTheme;
290
+ this.getCommandNames = getCommandNames;
154
291
  }
155
292
 
156
293
  override handleInput(data: string): void {
@@ -167,25 +304,42 @@ class PiCodeEditor extends CustomEditor {
167
304
  super.handleInput(data);
168
305
  }
169
306
 
307
+ private renderWithCommandHighlighting(
308
+ width: number,
309
+ color: (value: string) => string,
310
+ ): { lines: string[]; bottomBorderIndex: number } {
311
+ const lines = super.render(width);
312
+ const internals = this as unknown as {
313
+ lastWidth: number;
314
+ scrollOffset: number;
315
+ buildVisualLineMap: (layoutWidth: number) => EditorVisualLine[];
316
+ };
317
+ return highlightEditorLines(
318
+ lines,
319
+ this.getLines(),
320
+ internals.buildVisualLineMap(internals.lastWidth),
321
+ internals.scrollOffset,
322
+ this.getCommandNames(),
323
+ color,
324
+ );
325
+ }
326
+
170
327
  override render(width: number): string[] {
171
- if (width < 4) return super.render(width);
328
+ if (width <= 0) return [];
329
+ const colorCommand = (value: string): string => this.runtimeTheme.fg("mdLink", value);
330
+ if (width < 4) {
331
+ return this.renderWithCommandHighlighting(width, colorCommand)
332
+ .lines.map((line) => truncateToWidth(line, width, ""));
333
+ }
172
334
  const innerWidth = width - 2;
173
- const lines = super.render(innerWidth);
335
+ const highlighted = this.renderWithCommandHighlighting(innerWidth, colorCommand);
336
+ const { lines, bottomBorderIndex } = highlighted;
174
337
  if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
175
338
 
176
- const gray = (text: string): string => `\x1B[90m${text}\x1B[39m`;
177
- let bottomBorderIndex = -1;
178
- for (let index = lines.length - 1; index >= 1; index -= 1) {
179
- if (isBorderLine(lines[index] ?? "")) {
180
- bottomBorderIndex = index;
181
- break;
182
- }
183
- }
184
- if (bottomBorderIndex < 0) bottomBorderIndex = lines.length - 1;
185
-
339
+ const gray = (text: string): string => this.runtimeTheme.fg("dim", text);
186
340
  const framed: string[] = [];
187
341
  const top = stripAnsi(lines[0] ?? "");
188
- const isScrolledHeader = top.includes("");
342
+ const isScrolledHeader = isScrolledTopBorder(lines[0] ?? "");
189
343
  if (isScrolledHeader) {
190
344
  const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
191
345
  const indicator = `${gray("─── ↑ ")}${count}${gray(" more ")}${gray("─".repeat(Math.max(0, width - 12 - count.length)))}`;
@@ -215,38 +369,66 @@ class PiCodeEditor extends CustomEditor {
215
369
  }
216
370
  }
217
371
 
372
+ const ACTIVITY_FRAMES = [
373
+ "·", "✢", "✱", "✶", "✻", "✽",
374
+ "✽", "✻", "✶", "✱", "✢", "·",
375
+ ] as const;
376
+ const ACTIVITY_FRAME_INTERVAL_MS = 120;
218
377
  const ACTIVITY_WORDS = ["Brewing", "Pondering", "Tinkering", "Wrangling", "Noodling", "Cooking"] as const;
219
378
 
379
+ function formatActivityMessage(word: string, theme: Theme): string {
380
+ return `${theme.fg("accent", `${word}…`)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · thinking)`)}`;
381
+ }
382
+
220
383
  export function registerShellUi(pi: ExtensionAPI): void {
221
384
  let activeHeader: PiStartupHeader | undefined;
222
- let activityWordIndex = 0;
385
+ let activityDeck: string[] = [];
386
+ let lastActivityWord: string | undefined;
387
+ let activityTimer: ReturnType<typeof setInterval> | undefined;
223
388
  let tipDeck: string[] = [];
224
389
  const nextStartupTip = (): string => {
225
390
  if (tipDeck.length === 0) tipDeck = shuffledTips();
226
391
  return tipDeck.pop() ?? STARTUP_TIPS[0];
227
392
  };
393
+ const refillActivityDeck = (): void => {
394
+ activityDeck = [...ACTIVITY_WORDS];
395
+ for (let index = activityDeck.length - 1; index > 0; index -= 1) {
396
+ const swapIndex = Math.floor(Math.random() * (index + 1));
397
+ [activityDeck[index], activityDeck[swapIndex]] = [activityDeck[swapIndex]!, activityDeck[index]!];
398
+ }
399
+ if (activityDeck.length > 1 && activityDeck.at(-1) === lastActivityWord) {
400
+ [activityDeck[0], activityDeck[activityDeck.length - 1]] = [activityDeck.at(-1)!, activityDeck[0]!];
401
+ }
402
+ };
403
+ const nextActivityWord = (): string => {
404
+ if (activityDeck.length === 0) refillActivityDeck();
405
+ const word = activityDeck.pop() ?? ACTIVITY_WORDS[0];
406
+ lastActivityWord = word;
407
+ return word;
408
+ };
409
+ const clearActivityTimer = (): void => {
410
+ if (activityTimer) clearInterval(activityTimer);
411
+ activityTimer = undefined;
412
+ };
228
413
 
229
414
  pi.on("session_start", (_event, ctx) => {
230
415
  if (ctx.mode !== "tui") return;
231
416
  try {
232
417
  ctx.ui.setTheme("killeros");
233
418
  const startupTip = nextStartupTip();
234
- ctx.ui.setHeader(() => {
419
+ ctx.ui.setHeader((tui) => {
235
420
  activeHeader?.dispose();
236
- activeHeader = new PiStartupHeader(pi, ctx, startupTip);
421
+ activeHeader = new PiStartupHeader(pi, ctx, startupTip, tui);
237
422
  return activeHeader;
238
423
  });
424
+ clearActivityTimer();
239
425
  ctx.ui.setWorkingIndicator({
240
- frames: [
241
- ctx.ui.theme.fg("dim", "✻"),
242
- ctx.ui.theme.fg("muted", "✻"),
243
- ctx.ui.theme.fg("accent", "✻"),
244
- ctx.ui.theme.fg("muted", "✻"),
245
- ],
246
- intervalMs: 180,
426
+ frames: ACTIVITY_FRAMES.map((frame) => ctx.ui.theme.fg("accent", frame)),
427
+ intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
247
428
  });
248
429
  ctx.ui.setHiddenThinkingLabel("└ Thinking…");
249
- ctx.ui.setEditorComponent((tui, theme, keybindings) => new PiCodeEditor(tui, theme, keybindings));
430
+ ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
431
+ new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, () => availableCommandNames(pi)));
250
432
  } catch (error) {
251
433
  reportError(ctx, "Killeros UI failed to initialize", error);
252
434
  }
@@ -254,17 +436,23 @@ export function registerShellUi(pi: ExtensionAPI): void {
254
436
 
255
437
  pi.on("agent_start", (_event, ctx) => {
256
438
  if (ctx.mode !== "tui") return;
257
- ctx.ui.setWorkingMessage(`${ACTIVITY_WORDS[activityWordIndex]}…`);
258
- activityWordIndex = (activityWordIndex + 1) % ACTIVITY_WORDS.length;
439
+ clearActivityTimer();
440
+ const updateWorkingWord = (): void => ctx.ui.setWorkingMessage(formatActivityMessage(nextActivityWord(), ctx.ui.theme));
441
+ updateWorkingWord();
442
+ activityTimer = setInterval(updateWorkingWord, 2_500);
443
+ activityTimer.unref?.();
259
444
  });
260
445
 
261
446
  pi.on("agent_end", (_event, ctx) => {
447
+ clearActivityTimer();
262
448
  if (ctx.mode === "tui") ctx.ui.setWorkingMessage();
263
449
  });
264
450
 
265
451
  pi.on("session_shutdown", () => {
452
+ clearActivityTimer();
266
453
  activeHeader?.dispose();
267
454
  activeHeader = undefined;
268
- activityWordIndex = 0;
455
+ activityDeck = [];
456
+ lastActivityWord = undefined;
269
457
  });
270
458
  }
@@ -1,4 +1,4 @@
1
- import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import { DynamicBorder, keyHint, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
4
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
@@ -119,7 +119,11 @@ export function registerVariants(pi: ExtensionAPI): void {
119
119
  selectList.onCancel = () => done(null);
120
120
  container.addChild(selectList);
121
121
  container.addChild(new Text("", 0, 0));
122
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel"), 1, 0));
122
+ container.addChild(new Text(
123
+ theme.fg("dim", `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`),
124
+ 1,
125
+ 0,
126
+ ));
123
127
  container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
124
128
  return {
125
129
  render: (width) => container.render(width).map((line) => truncateToWidth(line, width, "")),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -4,6 +4,7 @@
4
4
  "vars": {
5
5
  "coral": "#d77757",
6
6
  "coralBright": "#e58b6d",
7
+ "commandBlue": "#78a9ff",
7
8
  "canvas": "#0a0a0a",
8
9
  "surface": "#121212",
9
10
  "surfaceRaised": "#1a1a1a",
@@ -43,7 +44,7 @@
43
44
  "toolOutput": "muted",
44
45
 
45
46
  "mdHeading": "coralBright",
46
- "mdLink": "coralBright",
47
+ "mdLink": "commandBlue",
47
48
  "mdLinkUrl": "dim",
48
49
  "mdCode": "coralBright",
49
50
  "mdCodeBlock": "text",
@@ -68,8 +69,8 @@
68
69
  "syntaxPunctuation": "muted",
69
70
 
70
71
  "thinkingOff": "dim",
71
- "thinkingMinimal": "#78685f",
72
- "thinkingLow": "#98705f",
72
+ "thinkingMinimal": "#927f74",
73
+ "thinkingLow": "#a27b6a",
73
74
  "thinkingMedium": "#b27762",
74
75
  "thinkingHigh": "coral",
75
76
  "thinkingXhigh": "#d58272",