killeros 2.0.3 → 2.0.5

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.
@@ -89,12 +89,24 @@ function oneLine(value: string): string {
89
89
  return value.replace(/\s+/gu, " ").trim();
90
90
  }
91
91
 
92
+ function boundedRenderLine(value: string, width: number, suffix: string): string {
93
+ return truncateToWidth(value.replace(/\r\n|\r|\n/gu, " "), width, suffix);
94
+ }
95
+
92
96
  function visibleOptionRange(total: number, selected: number, capacity: number): { start: number; end: number } {
93
97
  const size = Math.max(1, Math.min(total, capacity));
94
98
  const start = Math.max(0, Math.min(selected - Math.floor(size / 2), total - size));
95
99
  return { start, end: Math.min(total, start + size) };
96
100
  }
97
101
 
102
+ function boundedQuestionLines(question: string, width: number, rowLimit: number): string[] {
103
+ const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
104
+ if (wrapped.length <= rowLimit) return wrapped;
105
+ const visible = wrapped.slice(0, rowLimit);
106
+ visible[rowLimit - 1] = truncateToWidth(visible[rowLimit - 1]!, width, "…");
107
+ return visible;
108
+ }
109
+
98
110
  export function registerQuestionTool(pi: ExtensionAPI): void {
99
111
  const customInputHistory: string[] = [];
100
112
  let customInputHistoryBytes = 0;
@@ -349,27 +361,29 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
349
361
  if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
350
362
  const selected = visibleOptions[optionIndex];
351
363
  const position = `Option ${Math.min(optionIndex + 1, visibleOptions.length)}/${visibleOptions.length}`;
352
- const answerCount = inputCharacterCount(editor.getExpandedText()).toLocaleString();
364
+ const expandedAnswer = editor.getExpandedText();
365
+ const answerCount = inputCharacterCount(expandedAnswer).toLocaleString();
353
366
  const filterCount = inputCharacterCount(filterQuery).toLocaleString();
367
+ const compactDraft = expandedAnswer.replace(/\r\n|\r|\n/gu, " ↵ ") || "Type an answer";
354
368
 
355
369
  if (rowBudget <= 2) {
356
370
  const compact = editMode
357
- ? [`Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, editor.getExpandedText() || "Type an answer"]
371
+ ? [`Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, compactDraft]
358
372
  : [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
359
373
  cachedWidth = renderWidth;
360
374
  cachedRows = rowBudget;
361
- cachedLines = compact.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, "…"));
375
+ cachedLines = compact.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, "…"));
362
376
  return cachedLines;
363
377
  }
364
378
 
365
379
  if (rowBudget <= 5) {
366
380
  const compact = [
367
- truncateToWidth(params.question.replace(/\s+/gu, " "), renderWidth, "…"),
381
+ ...boundedQuestionLines(params.question, renderWidth, Math.max(1, rowBudget - 3)),
368
382
  editMode
369
383
  ? `Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
370
384
  : `${selected ? `> ${selected.label}` : "No matching options"} · ${position}`,
371
385
  editMode
372
- ? editor.getExpandedText() || "Type an answer"
386
+ ? compactDraft
373
387
  : filterQuery
374
388
  ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}`
375
389
  : position,
@@ -379,11 +393,12 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
379
393
  ];
380
394
  cachedWidth = renderWidth;
381
395
  cachedRows = rowBudget;
382
- cachedLines = compact.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, "…"));
396
+ cachedLines = compact.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, "…"));
383
397
  return cachedLines;
384
398
  }
385
399
 
386
- const contentRows = rowBudget - 5;
400
+ const questionLines = boundedQuestionLines(params.question, renderWidth, Math.max(1, rowBudget - 5));
401
+ const contentRows = rowBudget - questionLines.length - 4;
387
402
  const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
388
403
  const detailCapacity = Math.max(0, contentRows - optionCapacity);
389
404
  const { start, end } = visibleOptionRange(visibleOptions.length, optionIndex, optionCapacity);
@@ -401,7 +416,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
401
416
  : `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`;
402
417
  const lines: string[] = [
403
418
  theme.fg("accent", "─".repeat(renderWidth)),
404
- theme.fg("text", truncateToWidth(` ${params.question.replace(/\s+/gu, " ")}`, renderWidth, "…")),
419
+ ...questionLines.map((line) => theme.fg("text", line)),
405
420
  theme.fg("muted", truncateToWidth(` ${progress}`, renderWidth, "…")),
406
421
  ];
407
422
 
@@ -459,7 +474,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
459
474
  lines.push(theme.fg("accent", "─".repeat(renderWidth)));
460
475
  cachedWidth = renderWidth;
461
476
  cachedRows = rowBudget;
462
- cachedLines = lines.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, ""));
477
+ cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, ""));
463
478
  return cachedLines;
464
479
  };
465
480
 
@@ -1,15 +1,31 @@
1
+ import type { InitEvidenceIndex } from "./init-evidence.ts";
2
+ import type { InitTargetBaseline } from "./init-target.ts";
3
+
4
+ export type InitOutcome =
5
+ | { kind: "pending" }
6
+ | { kind: "written" }
7
+ | { kind: "policy-conflict"; reason: string }
8
+ | { kind: "no-outcome" };
9
+
1
10
  export interface InitRuntime {
2
11
  active: boolean;
3
12
  targetPath?: string;
4
- writeAttempted: boolean;
5
- writeSucceeded: boolean;
6
13
  projectRoot?: string;
7
14
  activeTools?: string[];
8
- settle?: (writeSucceeded: boolean) => void;
15
+ evidence?: InitEvidenceIndex;
16
+ baseline?: InitTargetBaseline;
17
+ outcome: InitOutcome;
18
+ settle?: (outcome: InitOutcome) => void;
9
19
  }
10
20
 
11
21
  export type GoalStatus = "active" | "paused" | "blocked" | "complete";
12
22
 
23
+ export interface GoalBlockerAudit {
24
+ key: string;
25
+ streak: number;
26
+ lastTurn: number;
27
+ }
28
+
13
29
  export interface GoalState {
14
30
  version: 1;
15
31
  revision: number;
@@ -24,6 +40,7 @@ export interface GoalState {
24
40
  baselineTokens: number;
25
41
  result?: string;
26
42
  resumeAfterManualCompaction?: true;
43
+ blockerAudit?: GoalBlockerAudit;
27
44
  }
28
45
 
29
46
  export interface GoalRuntime {
@@ -39,7 +56,7 @@ export interface GoalRuntime {
39
56
  }
40
57
 
41
58
  export function createInitRuntime(): InitRuntime {
42
- return { active: false, writeAttempted: false, writeSucceeded: false };
59
+ return { active: false, outcome: { kind: "pending" } };
43
60
  }
44
61
 
45
62
  export function createGoalRuntime(): GoalRuntime {
@@ -55,8 +72,10 @@ export function createGoalRuntime(): GoalRuntime {
55
72
  export function resetInitRuntime(state: InitRuntime): void {
56
73
  state.active = false;
57
74
  state.targetPath = undefined;
58
- state.writeAttempted = false;
59
- state.writeSucceeded = false;
60
75
  state.projectRoot = undefined;
61
76
  state.activeTools = undefined;
77
+ state.evidence = undefined;
78
+ state.baseline = undefined;
79
+ state.outcome = { kind: "pending" };
80
+ state.settle = undefined;
62
81
  }
@@ -19,7 +19,6 @@ import {
19
19
  type EditorTheme,
20
20
  type TUI,
21
21
  } from "@earendil-works/pi-tui";
22
- import { availableCommandNames } from "./commands.ts";
23
22
  import { formatCwd, padRight } from "./display.ts";
24
23
  import { reportError } from "./errors.ts";
25
24
  import { formatModel } from "./footer.ts";
@@ -45,6 +44,13 @@ const STARTUP_TIPS = [
45
44
  "Run /notification to enable a terminal bell when work settles.",
46
45
  ] as const;
47
46
 
47
+ const EDITOR_SUGGESTIONS = [
48
+ 'Try "how does <filepath> work?"',
49
+ 'Try "find edge cases in <filepath>"',
50
+ 'Try "simplify <filepath> without changing behavior"',
51
+ 'Try "write tests for <filepath>"',
52
+ ] as const;
53
+
48
54
  export function resolveGitBranch(cwd: string): Promise<string | undefined> {
49
55
  return new Promise((resolve) => {
50
56
  execFile(
@@ -68,13 +74,26 @@ export function resolveGitBranch(cwd: string): Promise<string | undefined> {
68
74
  });
69
75
  }
70
76
 
71
- function shuffledTips(): string[] {
72
- const tips = [...STARTUP_TIPS];
73
- for (let index = tips.length - 1; index > 0; index -= 1) {
77
+ function shuffledDeck(values: readonly string[]): string[] {
78
+ const deck = [...values];
79
+ for (let index = deck.length - 1; index > 0; index -= 1) {
74
80
  const swapIndex = Math.floor(Math.random() * (index + 1));
75
- [tips[index], tips[swapIndex]] = [tips[swapIndex]!, tips[index]!];
81
+ [deck[index], deck[swapIndex]] = [deck[swapIndex]!, deck[index]!];
76
82
  }
77
- return tips;
83
+ return deck;
84
+ }
85
+
86
+ let tipDeck: string[] = [];
87
+ let editorSuggestionDeck: string[] = [];
88
+
89
+ function nextStartupTip(): string {
90
+ if (tipDeck.length === 0) tipDeck = shuffledDeck(STARTUP_TIPS);
91
+ return tipDeck.pop() ?? STARTUP_TIPS[0];
92
+ }
93
+
94
+ function nextEditorSuggestion(): string {
95
+ if (editorSuggestionDeck.length === 0) editorSuggestionDeck = shuffledDeck(EDITOR_SUGGESTIONS);
96
+ return editorSuggestionDeck.pop() ?? EDITOR_SUGGESTIONS[0];
78
97
  }
79
98
 
80
99
  function compactBoxLine(content: string, width: number, theme: Theme): string {
@@ -151,113 +170,6 @@ class PiStartupHeader {
151
170
  }
152
171
 
153
172
  const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
154
- const ANSI_SEQUENCE_AT_START = /^\x1b\[[0-?]*[ -/]*[@-~]/u;
155
- const COMMAND_TOKEN_PATTERN = /(^|[ \t])(\/[A-Za-z0-9:_-]*)/gu;
156
-
157
- function controlSequenceAt(text: string, index: number): string | undefined {
158
- if (text.startsWith(CURSOR_MARKER, index)) return CURSOR_MARKER;
159
- return text.slice(index).match(ANSI_SEQUENCE_AT_START)?.[0];
160
- }
161
-
162
- interface CommandToken {
163
- text: string;
164
- start: number;
165
- end: number;
166
- valid: boolean;
167
- }
168
-
169
- interface EditorVisualLine {
170
- logicalLine: number;
171
- startCol: number;
172
- length: number;
173
- }
174
-
175
- function commandTokens(text: string, normalizedNames: readonly string[]): CommandToken[] {
176
- return [...text.matchAll(COMMAND_TOKEN_PATTERN)].map((match) => {
177
- const token = match[2] ?? "";
178
- const prefix = token.slice(1).toLocaleLowerCase();
179
- const start = (match.index ?? 0) + (match[1]?.length ?? 0);
180
- return {
181
- text: token,
182
- start,
183
- end: start + token.length,
184
- valid: normalizedNames.some((name) => name.startsWith(prefix)),
185
- };
186
- });
187
- }
188
-
189
- function highlightTextRanges(
190
- text: string,
191
- ranges: Array<{ start: number; end: number }>,
192
- color: (value: string) => string,
193
- ): string {
194
- if (ranges.length === 0) return text;
195
-
196
- let output = "";
197
- let buffer = "";
198
- let bufferHighlighted: boolean | undefined;
199
- let plainIndex = 0;
200
- const flush = (): void => {
201
- if (!buffer) return;
202
- output += bufferHighlighted ? color(buffer) : buffer;
203
- buffer = "";
204
- };
205
-
206
- for (let index = 0; index < text.length;) {
207
- const control = controlSequenceAt(text, index);
208
- if (control) {
209
- flush();
210
- output += control;
211
- index += control.length;
212
- continue;
213
- }
214
-
215
- const highlighted = ranges.some((range) => plainIndex >= range.start && plainIndex < range.end);
216
- if (bufferHighlighted !== highlighted) {
217
- flush();
218
- bufferHighlighted = highlighted;
219
- }
220
- buffer += text[index];
221
- plainIndex += 1;
222
- index += 1;
223
- }
224
- flush();
225
- return output;
226
- }
227
-
228
- function highlightEditorLines(
229
- lines: string[],
230
- sourceLines: string[],
231
- visualLines: EditorVisualLine[],
232
- scrollOffset: number,
233
- commandNames: ReadonlySet<string>,
234
- color: (value: string) => string,
235
- ): { lines: string[]; bottomBorderIndex: number } {
236
- let bottomBorderIndex = -1;
237
- for (let index = lines.length - 1; index >= 1; index -= 1) {
238
- if (isBorderLine(lines[index] ?? "")) {
239
- bottomBorderIndex = index;
240
- break;
241
- }
242
- }
243
- if (bottomBorderIndex < 0) bottomBorderIndex = lines.length - 1;
244
-
245
- const normalizedNames = [...commandNames].map((name) => name.toLocaleLowerCase());
246
- for (let index = 1; index < bottomBorderIndex; index += 1) {
247
- const visualLine = visualLines[scrollOffset + index - 1];
248
- if (!visualLine) continue;
249
- const visibleStart = visualLine.startCol;
250
- const visibleEnd = visibleStart + visualLine.length;
251
- const ranges = commandTokens(sourceLines[visualLine.logicalLine] ?? "", normalizedNames)
252
- .filter((token) => token.valid && token.start < visibleEnd && token.end > visibleStart)
253
- .map((token) => ({
254
- start: Math.max(token.start, visibleStart) - visibleStart,
255
- end: Math.min(token.end, visibleEnd) - visibleStart,
256
- }));
257
- lines[index] = highlightTextRanges(lines[index] ?? "", ranges, color);
258
- }
259
- return { lines, bottomBorderIndex };
260
- }
261
173
 
262
174
  function stripAnsi(text: string): string {
263
175
  return text.replace(ANSI_REGEX, "").trim();
@@ -276,19 +188,19 @@ function isScrolledTopBorder(line: string): boolean {
276
188
  class PiCodeEditor extends CustomEditor {
277
189
  private readonly appKeybindings: KeybindingsManager;
278
190
  private readonly runtimeTheme: Theme;
279
- private readonly getCommandNames: () => ReadonlySet<string>;
191
+ private readonly suggestion: string;
280
192
 
281
193
  constructor(
282
194
  tui: TUI,
283
195
  theme: EditorTheme,
284
196
  appKeybindings: KeybindingsManager,
285
197
  runtimeTheme: Theme,
286
- getCommandNames: () => ReadonlySet<string>,
198
+ suggestion: string,
287
199
  ) {
288
200
  super(tui, theme, appKeybindings);
289
201
  this.appKeybindings = appKeybindings;
290
202
  this.runtimeTheme = runtimeTheme;
291
- this.getCommandNames = getCommandNames;
203
+ this.suggestion = suggestion;
292
204
  }
293
205
 
294
206
  override handleInput(data: string): void {
@@ -305,37 +217,19 @@ class PiCodeEditor extends CustomEditor {
305
217
  super.handleInput(data);
306
218
  }
307
219
 
308
- private renderWithCommandHighlighting(
309
- width: number,
310
- color: (value: string) => string,
311
- ): { lines: string[]; bottomBorderIndex: number } {
312
- const lines = super.render(width);
313
- const internals = this as unknown as {
314
- lastWidth: number;
315
- scrollOffset: number;
316
- buildVisualLineMap: (layoutWidth: number) => EditorVisualLine[];
317
- };
318
- return highlightEditorLines(
319
- lines,
320
- this.getLines(),
321
- internals.buildVisualLineMap(internals.lastWidth),
322
- internals.scrollOffset,
323
- this.getCommandNames(),
324
- color,
325
- );
326
- }
327
-
328
220
  override render(width: number): string[] {
329
221
  if (width <= 0) return [];
330
- const colorCommand = (value: string): string => this.runtimeTheme.fg("mdLink", value);
331
- if (width < 4) {
332
- return this.renderWithCommandHighlighting(width, colorCommand)
333
- .lines.map((line) => truncateToWidth(line, width, ""));
334
- }
222
+ if (width < 4) return super.render(width).map((line) => truncateToWidth(line, width, ""));
335
223
  const innerWidth = width - 2;
336
- const highlighted = this.renderWithCommandHighlighting(innerWidth, colorCommand);
337
- const { lines, bottomBorderIndex } = highlighted;
224
+ const lines = super.render(innerWidth);
338
225
  if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
226
+ let bottomBorderIndex = lines.length - 1;
227
+ for (let index = lines.length - 1; index >= 1; index -= 1) {
228
+ if (isBorderLine(lines[index] ?? "")) {
229
+ bottomBorderIndex = index;
230
+ break;
231
+ }
232
+ }
339
233
 
340
234
  const gray = (text: string): string => this.runtimeTheme.fg("dim", text);
341
235
  const framed: string[] = [];
@@ -350,8 +244,16 @@ class PiCodeEditor extends CustomEditor {
350
244
  }
351
245
 
352
246
  for (let index = 1; index < bottomBorderIndex; index += 1) {
353
- const prefix = index === 1 && !isScrolledHeader ? gray("❯ ") : " ";
354
- framed.push(`${prefix}${padRight(lines[index] ?? "", innerWidth)}`);
247
+ const isPromptLine = index === 1 && !isScrolledHeader;
248
+ const prefix = isPromptLine ? gray("❯\u00A0") : " ";
249
+ let content = lines[index] ?? "";
250
+ if (isPromptLine && this.getText() === "") {
251
+ const first = this.suggestion.slice(0, 1);
252
+ const rest = this.suggestion.slice(1);
253
+ const cursorMarker = this.focused ? CURSOR_MARKER : "";
254
+ content = `${cursorMarker}\x1B[7m${gray(first)}\x1B[27m${gray(rest)}`;
255
+ }
256
+ framed.push(`${prefix}${padRight(content, innerWidth)}`);
355
257
  }
356
258
 
357
259
  const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
@@ -381,16 +283,13 @@ function formatActivityMessage(word: string, theme: Theme): string {
381
283
  return `${theme.fg("accent", `${word}…`)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · thinking)`)}`;
382
284
  }
383
285
 
286
+ let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
287
+
384
288
  export function registerShellUi(pi: ExtensionAPI): void {
385
289
  let activeHeader: PiStartupHeader | undefined;
386
290
  let activityDeck: string[] = [];
387
291
  let lastActivityWord: string | undefined;
388
292
  let activityTimer: ReturnType<typeof setInterval> | undefined;
389
- let tipDeck: string[] = [];
390
- const nextStartupTip = (): string => {
391
- if (tipDeck.length === 0) tipDeck = shuffledTips();
392
- return tipDeck.pop() ?? STARTUP_TIPS[0];
393
- };
394
293
  const refillActivityDeck = (): void => {
395
294
  activityDeck = [...ACTIVITY_WORDS];
396
295
  for (let index = activityDeck.length - 1; index > 0; index -= 1) {
@@ -428,8 +327,13 @@ export function registerShellUi(pi: ExtensionAPI): void {
428
327
  intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
429
328
  });
430
329
  ctx.ui.setHiddenThinkingLabel("└ Thinking…");
431
- ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
432
- new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, () => availableCommandNames(pi)));
330
+ const existingEditorFactory = ctx.ui.getEditorComponent?.();
331
+ if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
332
+ const editorSuggestion = nextEditorSuggestion();
333
+ killerosEditorFactory = (tui, editorTheme, keybindings) =>
334
+ new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion);
335
+ ctx.ui.setEditorComponent(killerosEditorFactory);
336
+ }
433
337
  } catch (error) {
434
338
  reportError(ctx, "Killeros UI failed to initialize", error);
435
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [