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 +23 -0
- package/README.md +8 -8
- package/killeros/bounded-text.ts +25 -0
- package/killeros/commands.ts +7 -0
- package/killeros/footer.ts +27 -2
- package/killeros/goals.ts +52 -24
- package/killeros/question.ts +144 -97
- package/killeros/shell-ui.ts +240 -52
- package/killeros/variants.ts +6 -2
- package/package.json +1 -1
- package/themes/killeros.json +4 -3
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.
|
|
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
|
-
-
|
|
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`
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/killeros/commands.ts
CHANGED
|
@@ -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]",
|
package/killeros/footer.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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,
|
|
349
|
+
renderResult(result, options, theme) {
|
|
333
350
|
const details = result.details;
|
|
334
|
-
return new
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/killeros/question.ts
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
520
|
+
return new BoundedText(first?.type === "text" ? first.text : "", options.expanded ? undefined : 3);
|
|
475
521
|
}
|
|
476
|
-
if (details.cancelled || details.answer === null) return new
|
|
522
|
+
if (details.cancelled || details.answer === null) return new BoundedText(theme.fg("warning", "Cancelled"));
|
|
477
523
|
if (details.wasCustom) {
|
|
478
|
-
|
|
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
|
|
527
|
+
return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", details.answer)}`);
|
|
481
528
|
},
|
|
482
529
|
});
|
|
483
530
|
}
|
package/killeros/shell-ui.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =>
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
455
|
+
activityDeck = [];
|
|
456
|
+
lastActivityWord = undefined;
|
|
269
457
|
});
|
|
270
458
|
}
|
package/killeros/variants.ts
CHANGED
|
@@ -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(
|
|
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
package/themes/killeros.json
CHANGED
|
@@ -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": "
|
|
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": "#
|
|
72
|
-
"thinkingLow": "#
|
|
72
|
+
"thinkingMinimal": "#927f74",
|
|
73
|
+
"thinkingLow": "#a27b6a",
|
|
73
74
|
"thinkingMedium": "#b27762",
|
|
74
75
|
"thinkingHigh": "coral",
|
|
75
76
|
"thinkingXhigh": "#d58272",
|