killeros 1.4.9 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
33
33
  Pin an install to a release:
34
34
 
35
35
  ```bash
36
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.9
36
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.5.0
37
37
  ```
38
38
 
39
39
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -97,7 +97,22 @@ KillerOS ships `planner`, `reviewer`, `scout`, and `security` as read-only roles
97
97
 
98
98
  The default `agentScope: "user"` uses bundled and personal roles. Use `"project"` or `"both"` to opt into trusted project roles; a selected project override requires interactive confirmation. Role frontmatter requires `name`, `description`, `access`, and an explicit `tools` list. Optional fields are `model`, `thinking`, and `timeoutMs`. Every bundled role shows `model: inherit` and `thinking: inherit` as editable placeholders. Replace them with an available `provider/model` and a separate thinking level when you want to pin a role; `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` are checked against that model’s supported capabilities.
99
99
 
100
- The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. Read-only-only batches run concurrently, up to four at a time. Batches with write-capable roles use one shared slot by default; set `writerConcurrency` above `1` only after proving path ownership in the shared worktree. Reader-only batches reject `writerConcurrency` because it does not apply. All children share the parent worktree, so concurrent writers must avoid file conflicts. A call can also set `model` and `thinking` for every task, overriding role settings; use `inherit` to fall back to each role and then the active parent model. The `message` field is only valid with `action: "steer"`. For example:
100
+ The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. Read-only-only batches run concurrently, up to four at a time. Batches with write-capable roles use one shared slot by default; set `writerConcurrency` above `1` only after proving path ownership in the shared worktree. Reader-only batches reject `writerConcurrency` because it does not apply. All children share the parent worktree, so concurrent writers must avoid file conflicts. A call can also set `model` and `thinking` for every task, overriding role settings; use `inherit` to fall back to each role and then the active parent model.
101
+
102
+ | Action | Required fields | Allowed optional fields |
103
+ |---|---|---|
104
+ | omitted / `spawn` single | `agent`, `task` | `model`, `thinking`, `agentScope` |
105
+ | omitted / `spawn` parallel | `tasks` | `writerConcurrency`, `model`, `thinking`, `agentScope` |
106
+ | omitted / `spawn` chain | `chain` | `model`, `thinking`, `agentScope` |
107
+ | `list` | none | none |
108
+ | `inspect` | `threadId` | none |
109
+ | `steer` | `threadId`, `message` | none |
110
+ | `interrupt` one | `threadId` | none |
111
+ | `interrupt` all | `all: true` | none |
112
+ | `collect` | `threadId` | none |
113
+ | `close` | `threadId` | none |
114
+
115
+ The three spawn shapes cannot be mixed. The `message` field is only valid with `action: "steer"`, and lifecycle actions reject spawn fields. KillerOS rejects malformed requests before role discovery, project confirmation, thread creation, or child launch. The TUI shows a parallel or shared-pool schedule only after shape validation; malformed calls show `invalid request` instead of queued work. For example:
101
116
 
102
117
  ```json
103
118
  {"agent":"reviewer","task":"Review the change","model":"provider/model","thinking":"high"}
@@ -163,7 +178,7 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
163
178
 
164
179
  The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog.
165
180
 
166
- For release `1.4.9`, publish after the validation checks pass:
181
+ For release `1.5.0`, publish after the validation checks pass:
167
182
 
168
183
  ```bash
169
184
  npm login
@@ -0,0 +1,164 @@
1
+ import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+
4
+ async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
5
+ if (!ctx.hasUI) return true;
6
+ return ctx.ui.confirm("Start new session", "Start a new session and leave the current history?");
7
+ }
8
+
9
+ export function registerAliases(pi: ExtensionAPI): void {
10
+ const startNewSession = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
11
+ await ctx.waitForIdle();
12
+ if (!await confirmNewSession(ctx)) return;
13
+ await ctx.newSession();
14
+ };
15
+ pi.registerCommand("clear", { description: "Start a new session after confirmation", handler: startNewSession });
16
+ pi.registerCommand("exit", {
17
+ description: "Quit Pi gracefully",
18
+ handler: async (_args, ctx) => ctx.shutdown(),
19
+ });
20
+ }
21
+
22
+ interface CommandInfo {
23
+ name: string;
24
+ description?: string;
25
+ category: "Built-in" | "Extension" | "Prompt" | "Skill";
26
+ syntaxHint?: string;
27
+ }
28
+
29
+ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
30
+ { name: "settings", description: "Open settings menu" },
31
+ { name: "model", description: "Select model" },
32
+ { name: "scoped-models", description: "Configure models for Ctrl+P cycling" },
33
+ { name: "export", description: "Export the current session" },
34
+ { name: "import", description: "Import and resume a JSONL session" },
35
+ { name: "share", description: "Share the session as a secret GitHub gist" },
36
+ { name: "copy", description: "Copy the last agent message" },
37
+ { name: "name", description: "Set the session display name" },
38
+ { name: "session", description: "Show session usage and stats" },
39
+ { name: "changelog", description: "Show changelog entries" },
40
+ { name: "hotkeys", description: "Show keyboard shortcuts" },
41
+ { name: "fork", description: "Fork from a previous user message" },
42
+ { name: "clone", description: "Duplicate the session at the current position" },
43
+ { name: "tree", description: "Navigate the session tree" },
44
+ { name: "trust", description: "Save the project trust decision" },
45
+ { name: "login", description: "Configure provider authentication" },
46
+ { name: "logout", description: "Remove provider authentication" },
47
+ { name: "new", description: "Start a new session" },
48
+ { name: "compact", description: "Compact the session context" },
49
+ { name: "resume", description: "Resume a different session" },
50
+ { name: "reload", description: "Reload extensions and resources" },
51
+ { name: "quit", description: "Quit Pi" },
52
+ ];
53
+
54
+ const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
55
+ goal: "/goal [objective|clear|edit|pause|resume]",
56
+ variants: "/variants [level]",
57
+ model: "/model [provider/model]",
58
+ "scoped-models": "/scoped-models",
59
+ login: "/login [provider]",
60
+ export: "/export [filename]",
61
+ import: "/import [path]",
62
+ name: "/name [session-name]",
63
+ };
64
+
65
+ interface TaggedAutocompleteItem extends AutocompleteItem {
66
+ killerosCommand?: string;
67
+ }
68
+
69
+ function scoreCommandMatch(name: string, prefix: string): number {
70
+ if (!prefix) return 1;
71
+ const normalizedName = name.toLocaleLowerCase();
72
+ const normalizedPrefix = prefix.toLocaleLowerCase();
73
+ if (normalizedName.startsWith(normalizedPrefix)) return 100;
74
+ if (normalizedName.split(/[:\-_]/).some((token) => token.startsWith(normalizedPrefix))) return 80;
75
+ if (normalizedName.includes(normalizedPrefix)) return 50;
76
+ return 0;
77
+ }
78
+
79
+ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
80
+ const usage = new Map<string, number>();
81
+ pi.on("session_start", (_event, ctx) => {
82
+ if (ctx.mode !== "tui") return;
83
+ ctx.ui.addAutocompleteProvider((current) => ({
84
+ triggerCharacters: ["/"],
85
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
86
+ const line = lines[cursorLine] ?? "";
87
+ const beforeCursor = line.slice(0, cursorCol);
88
+ const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
89
+ if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
90
+
91
+ const prefix = (match[1] ?? "").toLocaleLowerCase();
92
+ const baseSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
93
+ const commands = new Map<string, CommandInfo>();
94
+ BUILTIN_COMMANDS.forEach((command) => commands.set(command.name, {
95
+ ...command,
96
+ category: "Built-in",
97
+ syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
98
+ }));
99
+
100
+ for (const command of pi.getCommands()) {
101
+ const category: CommandInfo["category"] = command.source === "skill"
102
+ ? "Skill"
103
+ : command.source === "prompt"
104
+ ? "Prompt"
105
+ : "Extension";
106
+ commands.set(command.name, {
107
+ name: command.name,
108
+ description: command.description,
109
+ category,
110
+ syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
111
+ });
112
+ }
113
+
114
+ for (const item of baseSuggestions?.items ?? []) {
115
+ const name = (item.value || item.label).replace(/^\//, "").trim().split(/\s+/)[0] ?? "";
116
+ if (name && !commands.has(name)) {
117
+ commands.set(name, { name, description: item.description, category: "Built-in" });
118
+ }
119
+ }
120
+
121
+ const ranked = [...commands.values()]
122
+ .map((command) => ({
123
+ command,
124
+ score: scoreCommandMatch(command.name, prefix) + Math.min((usage.get(command.name) ?? 0) * 2, 15),
125
+ }))
126
+ .filter(({ command }) => scoreCommandMatch(command.name, prefix) > 0)
127
+ .sort((left, right) => right.score - left.score || left.command.name.localeCompare(right.command.name));
128
+ if (!ranked.length) return baseSuggestions;
129
+
130
+ return {
131
+ prefix: `/${prefix}`,
132
+ items: ranked.map(({ command }): TaggedAutocompleteItem => {
133
+ const syntax = command.syntaxHint ? `${command.syntaxHint} — ` : "";
134
+ return {
135
+ value: `/${command.name} `,
136
+ label: `/${command.name}`,
137
+ description: `[${command.category}] ${syntax}${command.description ?? ""}`.trim(),
138
+ killerosCommand: command.name,
139
+ };
140
+ }),
141
+ };
142
+ },
143
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
144
+ const tagged = item as TaggedAutocompleteItem;
145
+ if (!tagged.killerosCommand) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
146
+ usage.set(tagged.killerosCommand, (usage.get(tagged.killerosCommand) ?? 0) + 1);
147
+ const line = lines[cursorLine] ?? "";
148
+ const beforeCursor = line.slice(0, cursorCol);
149
+ let afterCursor = line.slice(cursorCol);
150
+ const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
151
+ if (!match || match.index === undefined) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
152
+ const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
153
+ const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
154
+ if (item.value.endsWith(" ") && afterCursor.startsWith(" ")) afterCursor = afterCursor.trimStart();
155
+ const nextLines = [...lines];
156
+ nextLines[cursorLine] = newBefore + afterCursor;
157
+ return { lines: nextLines, cursorLine, cursorCol: newBefore.length };
158
+ },
159
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
160
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
161
+ },
162
+ }));
163
+ });
164
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const CONCISE_SYSTEM_PROMPT = `
4
+ # Concise output rules
5
+ 1. Start with the answer or next action; omit conversational preambles.
6
+ 2. Use numbered steps only when order matters, with one bounded action per step.
7
+ 3. Finish the primary task before mentioning optional follow-up work.
8
+ 4. State failures directly and include the recovery action.
9
+ 5. Keep lists focused; group long inventories under clear headings.
10
+ 6. Do not invent time estimates, completion claims, or facts.
11
+ 7. Preserve exact code, commands, paths, quoted text, warnings, and user-requested formats.
12
+ 8. Omit recap sections and generic closing pleasantries.
13
+ `.trim();
14
+
15
+ export function isConcisedEnabled(): boolean {
16
+ return true;
17
+ }
18
+
19
+ export function registerConcisePrompt(pi: ExtensionAPI): void {
20
+ pi.on("before_agent_start", (event) => ({
21
+ systemPrompt: `${event.systemPrompt}\n\n${CONCISE_SYSTEM_PROMPT}`,
22
+ }));
23
+ }
@@ -0,0 +1,39 @@
1
+ import os from "node:os";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ export function formatCwd(cwd: string): string {
5
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
6
+ if (!home) return cwd;
7
+ const normalizedHome = home.replace(/[\\/]+$/, "");
8
+ const normalizedCwd = cwd.replace(/[\\/]+$/, "");
9
+ if (normalizedCwd === normalizedHome) return "~";
10
+ const separator = normalizedCwd.slice(normalizedHome.length, normalizedHome.length + 1);
11
+ return normalizedCwd.startsWith(normalizedHome) && (separator === "/" || separator === "\\")
12
+ ? `~${normalizedCwd.slice(normalizedHome.length)}`
13
+ : cwd;
14
+ }
15
+
16
+ export function padRight(text: string, width: number): string {
17
+ if (width <= 0) return "";
18
+ const clipped = truncateToWidth(text, width, "");
19
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
20
+ }
21
+
22
+ export function formatTime(milliseconds: number): string {
23
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
24
+ if (totalSeconds < 60) return `${totalSeconds}s`;
25
+ const minutes = Math.floor(totalSeconds / 60);
26
+ if (minutes < 60) return `${minutes}m`;
27
+ return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
28
+ }
29
+
30
+ export function formatTokens(value: number): string {
31
+ const amount = Math.max(0, value);
32
+ if (amount < 1_000) return `${Math.round(amount)}`;
33
+ if (amount >= 1_000_000) {
34
+ const precision = amount >= 10_000_000 ? 0 : 1;
35
+ return `${Number((amount / 1_000_000).toFixed(precision))}M`;
36
+ }
37
+ const precision = amount >= 100_000 ? 0 : 1;
38
+ return `${Number((amount / 1_000).toFixed(precision))}k`;
39
+ }
@@ -0,0 +1,6 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export function reportError(ctx: ExtensionContext, area: string, error: unknown): void {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ ctx.ui.notify(`${area}: ${message}`, "error");
6
+ }
@@ -0,0 +1,209 @@
1
+ import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Text, truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
3
+ import { formatCwd, formatTime, formatTokens, padRight } from "./display.ts";
4
+ import { goalElapsedMilliseconds } from "./goals.ts";
5
+ import type { GoalRuntime, GoalState } from "./runtime.ts";
6
+ import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
7
+
8
+ const FOOTER_REFRESH_INTERVAL_MS = 1_000;
9
+
10
+ export function formatCost(usd: number): string {
11
+ if (!Number.isFinite(usd)) return "$—";
12
+ return `$${usd.toFixed(2)}`;
13
+ }
14
+
15
+ export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
16
+ if (tokensUsed === null) return theme.fg("dim", "—% left (—)");
17
+ const windowSize = contextWindow > 0 ? contextWindow : 128_000;
18
+ const remaining = Math.max(0, Math.min(windowSize, windowSize - Math.max(0, tokensUsed)));
19
+ const percentLeft = Math.max(0, Math.min(100, Math.round((remaining / windowSize) * 100)));
20
+ const color: ThemeColor = percentLeft < 20 ? "error" : percentLeft <= 50 ? "warning" : "success";
21
+ const action = percentLeft < 15 ? " · /compact" : "";
22
+ return theme.fg(color, `${percentLeft}% left (${formatTokens(remaining)})${action}`);
23
+ }
24
+
25
+ function sumSessionCost(ctx: ExtensionContext): number {
26
+ let total = 0;
27
+ for (const entry of ctx.sessionManager.getEntries()) {
28
+ if (entry.type === "message" && entry.message.role === "assistant") total += entry.message.usage.cost.total;
29
+ else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
30
+ total += entry.message.usage.cost.total;
31
+ } else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
32
+ total += entry.usage.cost.total;
33
+ }
34
+ }
35
+ return total;
36
+ }
37
+
38
+ const PROVIDER_LABELS: Readonly<Record<string, string>> = {
39
+ "amazon-bedrock": "Amazon Bedrock",
40
+ "azure-openai-responses": "Azure OpenAI",
41
+ "github-copilot": "GitHub Copilot",
42
+ "google-vertex": "Google Vertex",
43
+ "openai-codex": "OpenAI",
44
+ anthropic: "Anthropic",
45
+ deepseek: "DeepSeek",
46
+ google: "Google",
47
+ ollama: "Ollama",
48
+ openai: "OpenAI",
49
+ openrouter: "OpenRouter",
50
+ };
51
+
52
+ const PROVIDER_WORDS: Readonly<Record<string, string>> = {
53
+ ai: "AI",
54
+ api: "API",
55
+ deepseek: "DeepSeek",
56
+ github: "GitHub",
57
+ llm: "LLM",
58
+ openai: "OpenAI",
59
+ openrouter: "OpenRouter",
60
+ };
61
+
62
+ function formatProviderName(provider: string): string {
63
+ const normalized = provider.trim();
64
+ const known = PROVIDER_LABELS[normalized.toLocaleLowerCase()];
65
+ if (known) return known;
66
+ return normalized
67
+ .split(/[-_]+/u)
68
+ .filter(Boolean)
69
+ .map((word) => PROVIDER_WORDS[word.toLocaleLowerCase()] ?? `${word.charAt(0).toLocaleUpperCase()}${word.slice(1)}`)
70
+ .join(" ") || "Unknown provider";
71
+ }
72
+
73
+ function modelDisplayName(model: NonNullable<ExtensionContext["model"]>): string {
74
+ return model.name?.trim() || model.id;
75
+ }
76
+
77
+ export function formatModel(model: ExtensionContext["model"], theme: Theme, includeProvider = true): string {
78
+ if (!model) return theme.fg("dim", "No model");
79
+ const name = theme.fg("text", theme.bold(modelDisplayName(model)));
80
+ return includeProvider ? `${name} ${theme.fg("dim", formatProviderName(model.provider))}` : name;
81
+ }
82
+
83
+ function compactDirectory(cwd: string): string {
84
+ if (cwd === "~" || cwd === "/" || /^[A-Za-z]:[\\/]?$/u.test(cwd)) return cwd;
85
+ const normalized = cwd.replace(/\\/gu, "/").replace(/\/$/u, "");
86
+ const finalSegment = normalized.split("/").at(-1);
87
+ return finalSegment ? `…/${finalSegment}` : cwd;
88
+ }
89
+
90
+ function joinFooterParts(parts: string[], theme: Theme): string {
91
+ return parts.filter(Boolean).join(theme.fg("dim", " · "));
92
+ }
93
+
94
+ function footerRowFits(left: string, right: string, width: number): boolean {
95
+ const contentWidth = visibleWidth(left) + (right ? visibleWidth(right) + 1 : 0);
96
+ return contentWidth + 2 <= width;
97
+ }
98
+
99
+ function renderFooterRow(left: string, right: string, width: number): string {
100
+ if (width <= 0) return "";
101
+ if (width < 3) return " ".repeat(width);
102
+
103
+ const innerWidth = width - 2;
104
+ if (!right) return ` ${padRight(left, innerWidth)} `;
105
+
106
+ const clippedRight = truncateToWidth(right, innerWidth, "");
107
+ const rightWidth = visibleWidth(clippedRight);
108
+ const leftBudget = Math.max(0, innerWidth - rightWidth - 1);
109
+ const clippedLeft = truncateToWidth(left, leftBudget, "…");
110
+ const gap = " ".repeat(Math.max(0, innerWidth - visibleWidth(clippedLeft) - rightWidth));
111
+ return ` ${clippedLeft}${gap}${clippedRight} `;
112
+ }
113
+
114
+ function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
115
+ if (!state) return "";
116
+ if (state.status === "active") return theme.fg("accent", `✻ goal · ${formatTime(goalElapsedMilliseconds(state))}`);
117
+ if (state.status === "paused") return theme.fg("warning", "Ⅱ goal paused");
118
+ if (state.status === "blocked") return theme.fg("error", "! goal blocked");
119
+ return theme.fg("success", "✓ goal complete");
120
+ }
121
+
122
+ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
123
+ let currentModel: ExtensionContext["model"];
124
+ let thinkingLevel: ThinkingLevel = "off";
125
+ let activeTui: TUI | undefined;
126
+ goalRuntime.requestRender = () => activeTui?.requestRender();
127
+
128
+ pi.on("session_start", (_event, ctx) => {
129
+ if (ctx.mode !== "tui") return;
130
+ const sessionStart = Date.now();
131
+ currentModel = ctx.model;
132
+ thinkingLevel = pi.getThinkingLevel() as ThinkingLevel;
133
+ const cwd = formatCwd(ctx.cwd);
134
+
135
+ ctx.ui.setFooter((tui, theme, footerData) => {
136
+ activeTui = tui;
137
+ const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
138
+ const refreshTimer = setInterval(() => tui.requestRender(), FOOTER_REFRESH_INTERVAL_MS);
139
+ refreshTimer.unref?.();
140
+ return {
141
+ dispose() {
142
+ unsubscribe();
143
+ clearInterval(refreshTimer);
144
+ if (activeTui === tui) activeTui = undefined;
145
+ },
146
+ invalidate() {},
147
+ render(width: number): string[] {
148
+ if (width <= 0) return [];
149
+ const model = currentModel ?? ctx.model;
150
+ const level = model?.reasoning === false
151
+ ? theme.fg("thinkingOff", "no reasoning")
152
+ : theme.fg(LEVEL_COLORS[thinkingLevel], thinkingLevel);
153
+ const usage = ctx.getContextUsage();
154
+ const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 128_000;
155
+ const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
156
+ const branch = footerData.getGitBranch();
157
+ const signature = formatModel(model, theme);
158
+ const fullDirectory = theme.fg("dim", cwd);
159
+ const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
160
+ const goal = formatGoalFooter(goalRuntime.state, theme);
161
+ const rich = joinFooterParts([
162
+ signature,
163
+ level,
164
+ context,
165
+ goal,
166
+ branch ? theme.fg("dim", branch) : "",
167
+ theme.fg("dim", formatTime(Date.now() - sessionStart)),
168
+ theme.fg("dim", formatCost(sumSessionCost(ctx))),
169
+ ], theme);
170
+ const focused = joinFooterParts([signature, context, goal], theme);
171
+
172
+ if (footerRowFits(rich, fullDirectory, width)) {
173
+ return [renderFooterRow(rich, fullDirectory, width)];
174
+ }
175
+ if (footerRowFits(rich, focusedDirectory, width)) {
176
+ return [renderFooterRow(rich, focusedDirectory, width)];
177
+ }
178
+ if (footerRowFits(focused, focusedDirectory, width)) {
179
+ return [renderFooterRow(focused, focusedDirectory, width)];
180
+ }
181
+ if (footerRowFits(focused, "", width)) {
182
+ return [renderFooterRow(focused, "", width)];
183
+ }
184
+ if (goal) {
185
+ const essentialGoal = joinFooterParts([context, goal], theme);
186
+ if (footerRowFits(essentialGoal, "", width)) return [renderFooterRow(essentialGoal, "", width)];
187
+ return [renderFooterRow(goal, context, width)];
188
+ }
189
+
190
+ const essentialModel = formatModel(model, theme, false);
191
+ return [renderFooterRow(essentialModel, context, width)];
192
+ },
193
+ };
194
+ });
195
+ });
196
+
197
+ pi.on("model_select", (event) => {
198
+ currentModel = event.model;
199
+ activeTui?.requestRender();
200
+ });
201
+ pi.on("thinking_level_select", (event) => {
202
+ thinkingLevel = event.level;
203
+ activeTui?.requestRender();
204
+ });
205
+ pi.on("session_shutdown", () => {
206
+ activeTui = undefined;
207
+ goalRuntime.requestRender = undefined;
208
+ });
209
+ }