@oh-my-pi/pi-coding-agent 3.6.1337 → 3.9.1337

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.
@@ -6,15 +6,43 @@ import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"
6
6
  import type { AgentSession } from "../../../core/agent-session";
7
7
  import { theme } from "../theme/theme";
8
8
 
9
- // Nerd Font icons (matching Claude/statusline-nerd.sh)
9
+ // Thinking level icons (Nerd Font)
10
+ const THINKING_ICONS: Record<string, string> = {
11
+ minimal: "🤨 min",
12
+ low: "🤔 low",
13
+ medium: "🤓 mid",
14
+ high: "🤯 high",
15
+ xhigh: "🧠 xhi",
16
+ };
17
+
18
+ // Nerd Font icons
10
19
  const ICONS = {
11
20
  model: "\uf4bc", // robot/model
12
21
  folder: "\uf115", // folder
13
- branch: "\uf126", // git branch
22
+ branch: "\ue725", // git branch
14
23
  sep: "\ue0b1", // powerline thin chevron
15
- tokens: "\uf0ce", // table/tokens
24
+ tokens: "\ue26b", // coins
25
+ context: "\ue70f", // window
26
+ auto: "\udb80\udc68", // auto
16
27
  } as const;
17
28
 
29
+ /** Create a colored text segment with background */
30
+ function plSegment(content: string, fgAnsi: string, bgAnsi: string): string {
31
+ return `${bgAnsi}${fgAnsi} ${content} \x1b[0m`;
32
+ }
33
+
34
+ /** Create separator with background */
35
+ function plSep(sepAnsi: string, bgAnsi: string): string {
36
+ return `${bgAnsi}${sepAnsi}${ICONS.sep}\x1b[0m`;
37
+ }
38
+
39
+ /** Create end cap - solid arrow transitioning bg to terminal default */
40
+ function plEnd(bgAnsi: string): string {
41
+ // Use the bg color as fg for the arrow (creates the triangle effect)
42
+ const fgFromBg = bgAnsi.replace("\x1b[48;", "\x1b[38;");
43
+ return `${fgFromBg}\ue0b0\x1b[0m`;
44
+ }
45
+
18
46
  /**
19
47
  * Sanitize text for display in a single-line status.
20
48
  * Removes newlines, tabs, carriage returns, and other control characters.
@@ -50,7 +78,7 @@ function findGitHeadPath(): string | null {
50
78
  /**
51
79
  * Footer component that shows pwd, token stats, and context usage
52
80
  */
53
- export class FooterComponent implements Component {
81
+ export class StatusLineComponent implements Component {
54
82
  private session: AgentSession;
55
83
  private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
56
84
  private gitWatcher: FSWatcher | null = null;
@@ -220,7 +248,7 @@ export class FooterComponent implements Component {
220
248
  }
221
249
  }
222
250
 
223
- render(width: number): string[] {
251
+ private buildStatusLine(): string {
224
252
  const state = this.session.state;
225
253
 
226
254
  // Calculate cumulative usage from ALL session entries
@@ -264,132 +292,157 @@ export class FooterComponent implements Component {
264
292
  return `${Math.round(n / 1000000)}M`;
265
293
  };
266
294
 
267
- // Powerline separator (very dim)
268
- const sep = theme.fg("footerSep", ` ${ICONS.sep} `);
269
-
270
295
  // ═══════════════════════════════════════════════════════════════════════
271
- // SEGMENT 1: Model (Gold/White)
296
+ // SEGMENT 1: Model
272
297
  // ═══════════════════════════════════════════════════════════════════════
273
- const modelName = state.model?.id || "no-model";
274
- let modelSegment = theme.fg("footerModel", `${ICONS.model} ${modelName}`);
298
+ let modelName = state.model?.name || state.model?.id || "no-model";
299
+ // Strip "Claude " prefix for brevity
300
+ if (modelName.startsWith("Claude ")) {
301
+ modelName = modelName.slice(7);
302
+ }
303
+ let modelContent = `${ICONS.model} ${modelName}`;
275
304
  if (state.model?.reasoning) {
276
305
  const level = state.thinkingLevel || "off";
277
306
  if (level !== "off") {
278
- modelSegment += theme.fg("footerSep", " · ") + theme.fg("footerModel", level);
307
+ modelContent += ` · ${THINKING_ICONS[level] ?? level}`;
279
308
  }
280
309
  }
281
310
 
282
311
  // ═══════════════════════════════════════════════════════════════════════
283
- // SEGMENT 2: Path (Cyan with dim separators)
284
- // Replace home with ~, strip /work/, color separators
312
+ // SEGMENT 2: Path
285
313
  // ═══════════════════════════════════════════════════════════════════════
286
314
  let pwd = process.cwd();
287
315
  const home = process.env.HOME || process.env.USERPROFILE;
288
316
  if (home && pwd.startsWith(home)) {
289
317
  pwd = `~${pwd.slice(home.length)}`;
290
318
  }
291
- // Strip /work/ prefix
292
319
  if (pwd.startsWith("/work/")) {
293
320
  pwd = pwd.slice(6);
294
321
  }
295
- // Color path with dim separators: ~/foo/bar -> ~/foo/bar (separators dim)
296
- const pathColored = pwd
297
- .split("/")
298
- .map((part) => theme.fg("footerPath", part))
299
- .join(theme.fg("footerSep", "/"));
300
- const pathSegment = theme.fg("footerIcon", `${ICONS.folder} `) + pathColored;
322
+ const pathContent = `${ICONS.folder} ${pwd}`;
301
323
 
302
324
  // ═══════════════════════════════════════════════════════════════════════
303
- // SEGMENT 3: Git Branch + Status (Green/Yellow)
325
+ // SEGMENT 3: Git Branch + Status
304
326
  // ═══════════════════════════════════════════════════════════════════════
305
327
  const branch = this.getCurrentBranch();
306
- let gitSegment = "";
328
+ let gitContent = "";
329
+ let gitColorName: "statusLineGitClean" | "statusLineGitDirty" = "statusLineGitClean";
307
330
  if (branch) {
308
331
  const gitStatus = this.getGitStatus();
309
332
  const isDirty = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0 || gitStatus.untracked > 0);
333
+ gitColorName = isDirty ? "statusLineGitDirty" : "statusLineGitClean";
310
334
 
311
- // Branch name - green if clean, yellow if dirty
312
- const branchColor = isDirty ? "footerDirty" : "footerBranch";
313
- gitSegment = theme.fg("footerIcon", `${ICONS.branch} `) + theme.fg(branchColor, branch);
335
+ gitContent = `${ICONS.branch} ${branch}`;
314
336
 
315
- // Add status indicators
316
337
  if (gitStatus) {
317
338
  const indicators: string[] = [];
318
339
  if (gitStatus.unstaged > 0) {
319
- indicators.push(theme.fg("footerDirty", `*${gitStatus.unstaged}`));
340
+ indicators.push(theme.fg("statusLineDirty", `*${gitStatus.unstaged}`));
320
341
  }
321
342
  if (gitStatus.staged > 0) {
322
- indicators.push(theme.fg("footerStaged", `+${gitStatus.staged}`));
343
+ indicators.push(theme.fg("statusLineStaged", `+${gitStatus.staged}`));
323
344
  }
324
345
  if (gitStatus.untracked > 0) {
325
- indicators.push(theme.fg("footerUntracked", `!${gitStatus.untracked}`));
346
+ indicators.push(theme.fg("statusLineUntracked", `?${gitStatus.untracked}`));
326
347
  }
327
348
  if (indicators.length > 0) {
328
- gitSegment += ` ${indicators.join(" ")}`;
349
+ gitContent += ` ${indicators.join(" ")}`;
329
350
  }
330
351
  }
331
352
  }
332
353
 
333
354
  // ═══════════════════════════════════════════════════════════════════════
334
- // SEGMENT 4: Stats (Pink/Magenta tones)
335
- // Concise: total tokens, cost, context%
355
+ // SEGMENT 4: Context (window usage)
336
356
  // ═══════════════════════════════════════════════════════════════════════
337
- const statParts: string[] = [];
357
+ const autoIndicator = this.autoCompactEnabled ? ` ${ICONS.auto}` : "";
358
+ const contextText = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`;
359
+ let contextContent: string;
360
+ if (contextPercentValue > 90) {
361
+ contextContent = `${ICONS.context} ${theme.fg("error", contextText)}`;
362
+ } else if (contextPercentValue > 70) {
363
+ contextContent = `${ICONS.context} ${theme.fg("warning", contextText)}`;
364
+ } else {
365
+ contextContent = `${ICONS.context} ${contextText}`;
366
+ }
367
+
368
+ // ═══════════════════════════════════════════════════════════════════════
369
+ // SEGMENT 5: Spend (tokens + cost)
370
+ // ═══════════════════════════════════════════════════════════════════════
371
+ const spendParts: string[] = [];
338
372
 
339
- // Total tokens (input + output + cache)
340
373
  const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
341
374
  if (totalTokens) {
342
- statParts.push(theme.fg("footerOutput", `${ICONS.tokens} ${formatTokens(totalTokens)}`));
375
+ spendParts.push(`${ICONS.tokens} ${formatTokens(totalTokens)}`);
343
376
  }
344
377
 
345
- // Cost (pink)
346
378
  const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
347
379
  if (totalCost || usingSubscription) {
348
- const costDisplay = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
349
- statParts.push(theme.fg("footerCost", costDisplay));
380
+ const costDisplay = `$${totalCost.toFixed(2)}${usingSubscription ? " (sub)" : ""}`;
381
+ spendParts.push(costDisplay);
350
382
  }
351
383
 
352
- // Context percentage with severity coloring
353
- const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
354
- const contextDisplay = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`;
355
- let contextColored: string;
356
- if (contextPercentValue > 90) {
357
- contextColored = theme.fg("error", contextDisplay);
358
- } else if (contextPercentValue > 70) {
359
- contextColored = theme.fg("warning", contextDisplay);
360
- } else {
361
- contextColored = theme.fg("footerSep", contextDisplay);
362
- }
363
- statParts.push(contextColored);
364
-
365
- const statsSegment = statParts.join(" ");
384
+ const spendContent = theme.fg("statusLineCost", spendParts.join(" · "));
366
385
 
367
386
  // ═══════════════════════════════════════════════════════════════════════
368
- // Assemble single powerline-style line
369
- // [Model] > [Path] > [Git] > [Stats]
387
+ // Assemble: [Model] > [Path] > [Git?] > [Context] > [Spend] >
370
388
  // ═══════════════════════════════════════════════════════════════════════
371
- const segments = [modelSegment, pathSegment];
372
- if (gitSegment) segments.push(gitSegment);
373
- segments.push(statsSegment);
389
+ const bgAnsi = theme.getBgAnsi("statusLineBg");
390
+ const sepAnsi = theme.getFgAnsi("statusLineSep");
391
+
392
+ let statusLine = "";
393
+
394
+ // Model segment
395
+ statusLine += plSegment(modelContent, theme.getFgAnsi("statusLineModel"), bgAnsi);
396
+ statusLine += plSep(sepAnsi, bgAnsi);
374
397
 
375
- let statusLine = segments.join(sep);
398
+ // Path segment
399
+ statusLine += plSegment(pathContent, theme.getFgAnsi("statusLinePath"), bgAnsi);
376
400
 
377
- // Truncate if needed
378
- if (visibleWidth(statusLine) > width) {
379
- statusLine = truncateToWidth(statusLine, width, theme.fg("footerSep", "…"));
401
+ if (gitContent) {
402
+ statusLine += plSep(sepAnsi, bgAnsi);
403
+ statusLine += plSegment(gitContent, theme.getFgAnsi(gitColorName), bgAnsi);
380
404
  }
381
405
 
382
- const lines = [statusLine];
406
+ // Context segment
407
+ statusLine += plSep(sepAnsi, bgAnsi);
408
+ statusLine += plSegment(contextContent, theme.getFgAnsi("statusLineContext"), bgAnsi);
409
+
410
+ // Spend segment
411
+ statusLine += plSep(sepAnsi, bgAnsi);
412
+ statusLine += plSegment(spendContent, theme.getFgAnsi("statusLineSpend"), bgAnsi);
413
+
414
+ // End cap (solid arrow to terminal bg)
415
+ statusLine += plEnd(bgAnsi);
416
+
417
+ return statusLine;
418
+ }
383
419
 
384
- // Hook statuses (optional second line)
385
- if (this.hookStatuses.size > 0) {
386
- const sortedStatuses = Array.from(this.hookStatuses.entries())
387
- .sort(([a], [b]) => a.localeCompare(b))
388
- .map(([, text]) => sanitizeStatusText(text));
389
- const hookLine = sortedStatuses.join(" ");
390
- lines.push(truncateToWidth(hookLine, width, theme.fg("footerSep", "…")));
420
+ /**
421
+ * Get the status line content for use as editor top border.
422
+ * Returns the content string and its visible width.
423
+ */
424
+ getTopBorder(_width: number): { content: string; width: number } {
425
+ const content = this.buildStatusLine();
426
+ return {
427
+ content,
428
+ width: visibleWidth(content),
429
+ };
430
+ }
431
+
432
+ /**
433
+ * Render only hook statuses (if any).
434
+ * Used when footer is integrated into editor border.
435
+ */
436
+ render(width: number): string[] {
437
+ // Only render hook statuses - main status is in editor's top border
438
+ if (this.hookStatuses.size === 0) {
439
+ return [];
391
440
  }
392
441
 
393
- return lines;
442
+ const sortedStatuses = Array.from(this.hookStatuses.entries())
443
+ .sort(([a], [b]) => a.localeCompare(b))
444
+ .map(([, text]) => sanitizeStatusText(text));
445
+ const hookLine = sortedStatuses.join(" ");
446
+ return [truncateToWidth(hookLine, width, theme.fg("statusLineSep", "…"))];
394
447
  }
395
448
  }
@@ -503,19 +503,19 @@ export class ToolExecutionComponent extends Container {
503
503
  }
504
504
 
505
505
  // Show LSP diagnostics if available
506
- if (this.result?.details?.diagnostics?.available) {
506
+ if (this.result?.details?.diagnostics) {
507
507
  const diag = this.result.details.diagnostics;
508
- if (diag.diagnostics.length > 0) {
509
- const icon = diag.hasErrors ? theme.fg("error", "●") : theme.fg("warning", "●");
508
+ if (diag.messages.length > 0) {
509
+ const icon = diag.errored ? theme.fg("error", "●") : theme.fg("warning", "●");
510
510
  text += `\n\n${icon} ${theme.fg("toolTitle", "LSP Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
511
- const maxDiags = this.expanded ? diag.diagnostics.length : 5;
512
- const displayDiags = diag.diagnostics.slice(0, maxDiags);
511
+ const maxDiags = this.expanded ? diag.messages.length : 5;
512
+ const displayDiags = diag.messages.slice(0, maxDiags);
513
513
  for (const d of displayDiags) {
514
514
  const color = d.includes("[error]") ? "error" : d.includes("[warning]") ? "warning" : "dim";
515
515
  text += `\n ${theme.fg(color, d)}`;
516
516
  }
517
- if (diag.diagnostics.length > maxDiags) {
518
- text += theme.fg("dim", `\n ... (${diag.diagnostics.length - maxDiags} more)`);
517
+ if (diag.messages.length > maxDiags) {
518
+ text += theme.fg("dim", `\n ... (${diag.messages.length - maxDiags} more)`);
519
519
  }
520
520
  }
521
521
  }
@@ -552,19 +552,19 @@ export class ToolExecutionComponent extends Container {
552
552
  }
553
553
 
554
554
  // Show LSP diagnostics if available
555
- if (this.result?.details?.diagnostics?.available) {
555
+ if (this.result?.details?.diagnostics) {
556
556
  const diag = this.result.details.diagnostics;
557
- if (diag.diagnostics.length > 0) {
558
- const icon = diag.hasErrors ? theme.fg("error", "●") : theme.fg("warning", "●");
557
+ if (diag.messages.length > 0) {
558
+ const icon = diag.errored ? theme.fg("error", "●") : theme.fg("warning", "●");
559
559
  text += `\n\n${icon} ${theme.fg("toolTitle", "LSP Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
560
- const maxDiags = this.expanded ? diag.diagnostics.length : 5;
561
- const displayDiags = diag.diagnostics.slice(0, maxDiags);
560
+ const maxDiags = this.expanded ? diag.messages.length : 5;
561
+ const displayDiags = diag.messages.slice(0, maxDiags);
562
562
  for (const d of displayDiags) {
563
563
  const color = d.includes("[error]") ? "error" : d.includes("[warning]") ? "warning" : "dim";
564
564
  text += `\n ${theme.fg(color, d)}`;
565
565
  }
566
- if (diag.diagnostics.length > maxDiags) {
567
- text += theme.fg("dim", `\n ... (${diag.diagnostics.length - maxDiags} more)`);
566
+ if (diag.messages.length > maxDiags) {
567
+ text += theme.fg("dim", `\n ... (${diag.messages.length - maxDiags} more)`);
568
568
  }
569
569
  }
570
570
  }
@@ -43,7 +43,6 @@ import { CompactionSummaryMessageComponent } from "./components/compaction-summa
43
43
  import { CustomEditor } from "./components/custom-editor";
44
44
  import { DynamicBorder } from "./components/dynamic-border";
45
45
  import { ExtensionDashboard } from "./components/extensions";
46
- import { FooterComponent } from "./components/footer";
47
46
  import { HookEditorComponent } from "./components/hook-editor";
48
47
  import { HookInputComponent } from "./components/hook-input";
49
48
  import { HookMessageComponent } from "./components/hook-message";
@@ -52,6 +51,7 @@ import { ModelSelectorComponent } from "./components/model-selector";
52
51
  import { OAuthSelectorComponent } from "./components/oauth-selector";
53
52
  import { SessionSelectorComponent } from "./components/session-selector";
54
53
  import { SettingsSelectorComponent } from "./components/settings-selector";
54
+ import { StatusLineComponent } from "./components/status-line";
55
55
  import { ToolExecutionComponent } from "./components/tool-execution";
56
56
  import { TreeSelectorComponent } from "./components/tree-selector";
57
57
  import { TtsrNotificationComponent } from "./components/ttsr-notification";
@@ -85,7 +85,7 @@ export class InteractiveMode {
85
85
  private statusContainer: Container;
86
86
  private editor: CustomEditor;
87
87
  private editorContainer: Container;
88
- private footer: FooterComponent;
88
+ private statusLine: StatusLineComponent;
89
89
  private version: string;
90
90
  private isInitialized = false;
91
91
  private onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void;
@@ -143,9 +143,6 @@ export class InteractiveMode {
143
143
  // Custom tools for custom rendering
144
144
  private customTools: Map<string, LoadedCustomTool>;
145
145
 
146
- // Title generation state
147
- private titleGenerationAttempted = false;
148
-
149
146
  // Convenience accessors
150
147
  private get agent() {
151
148
  return this.session.agent;
@@ -179,8 +176,8 @@ export class InteractiveMode {
179
176
  this.editor = new CustomEditor(getEditorTheme());
180
177
  this.editorContainer = new Container();
181
178
  this.editorContainer.addChild(this.editor);
182
- this.footer = new FooterComponent(session);
183
- this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
179
+ this.statusLine = new StatusLineComponent(session);
180
+ this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled);
184
181
 
185
182
  // Define slash commands for autocomplete
186
183
  const slashCommands: SlashCommand[] = [
@@ -261,7 +258,6 @@ export class InteractiveMode {
261
258
  const existingTitle = this.sessionManager.getSessionTitle();
262
259
  if (existingTitle) {
263
260
  setTerminalTitle(`pi: ${existingTitle}`);
264
- this.titleGenerationAttempted = true; // Don't try to generate again
265
261
  }
266
262
 
267
263
  // Setup UI layout
@@ -291,7 +287,7 @@ export class InteractiveMode {
291
287
  this.ui.addChild(this.statusContainer);
292
288
  this.ui.addChild(new Spacer(1));
293
289
  this.ui.addChild(this.editorContainer);
294
- this.ui.addChild(this.footer);
290
+ this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
295
291
  this.ui.setFocus(this.editor);
296
292
 
297
293
  this.setupKeyHandlers();
@@ -315,9 +311,13 @@ export class InteractiveMode {
315
311
  });
316
312
 
317
313
  // Set up git branch watcher
318
- this.footer.watchBranch(() => {
314
+ this.statusLine.watchBranch(() => {
315
+ this.updateEditorTopBorder();
319
316
  this.ui.requestRender();
320
317
  });
318
+
319
+ // Initial top border update
320
+ this.updateEditorTopBorder();
321
321
  }
322
322
 
323
323
  // =========================================================================
@@ -401,7 +401,6 @@ export class InteractiveMode {
401
401
  this.streamingComponent = undefined;
402
402
  this.streamingMessage = undefined;
403
403
  this.pendingTools.clear();
404
- this.titleGenerationAttempted = false;
405
404
 
406
405
  this.chatContainer.addChild(new Spacer(1));
407
406
  this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
@@ -497,7 +496,7 @@ export class InteractiveMode {
497
496
  * Set hook status text in the footer.
498
497
  */
499
498
  private setHookStatus(key: string, text: string | undefined): void {
500
- this.footer.setHookStatus(key, text);
499
+ this.statusLine.setHookStatus(key, text);
501
500
  this.ui.requestRender();
502
501
  }
503
502
 
@@ -903,6 +902,21 @@ export class InteractiveMode {
903
902
  // First, move any pending bash components to chat
904
903
  this.flushPendingBashComponents();
905
904
 
905
+ // Generate session title on first message
906
+ const hasUserMessages = this.agent.state.messages.some((m) => m.role === "user");
907
+ if (!hasUserMessages && !this.sessionManager.getSessionTitle()) {
908
+ const registry = this.session.modelRegistry;
909
+ const smolModel = this.settingsManager.getModelRole("smol");
910
+ generateSessionTitle(text, registry, smolModel)
911
+ .then((title) => {
912
+ if (title) {
913
+ this.sessionManager.setSessionTitle(title);
914
+ setTerminalTitle(`omp: ${title}`);
915
+ }
916
+ })
917
+ .catch(() => {});
918
+ }
919
+
906
920
  if (this.onInputCallback) {
907
921
  // Include any pending images from clipboard paste
908
922
  const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;
@@ -924,7 +938,8 @@ export class InteractiveMode {
924
938
  await this.init();
925
939
  }
926
940
 
927
- this.footer.invalidate();
941
+ this.statusLine.invalidate();
942
+ this.updateEditorTopBorder();
928
943
 
929
944
  switch (event.type) {
930
945
  case "agent_start":
@@ -1029,7 +1044,8 @@ export class InteractiveMode {
1029
1044
  }
1030
1045
  this.streamingComponent = undefined;
1031
1046
  this.streamingMessage = undefined;
1032
- this.footer.invalidate();
1047
+ this.statusLine.invalidate();
1048
+ this.updateEditorTopBorder();
1033
1049
  }
1034
1050
  this.ui.requestRender();
1035
1051
  break;
@@ -1085,12 +1101,6 @@ export class InteractiveMode {
1085
1101
  }
1086
1102
  this.pendingTools.clear();
1087
1103
  this.ui.requestRender();
1088
-
1089
- // Generate session title after first turn (if not already titled)
1090
- if (!this.titleGenerationAttempted && !this.sessionManager.getSessionTitle()) {
1091
- this.titleGenerationAttempted = true;
1092
- this.maybeGenerateTitle();
1093
- }
1094
1104
  break;
1095
1105
 
1096
1106
  case "auto_compaction_start": {
@@ -1143,7 +1153,8 @@ export class InteractiveMode {
1143
1153
  summary: event.result.summary,
1144
1154
  timestamp: Date.now(),
1145
1155
  });
1146
- this.footer.invalidate();
1156
+ this.statusLine.invalidate();
1157
+ this.updateEditorTopBorder();
1147
1158
  }
1148
1159
  this.ui.requestRender();
1149
1160
  break;
@@ -1320,7 +1331,7 @@ export class InteractiveMode {
1320
1331
  this.pendingTools.clear();
1321
1332
 
1322
1333
  if (options.updateFooter) {
1323
- this.footer.invalidate();
1334
+ this.statusLine.invalidate();
1324
1335
  this.updateEditorBorderColor();
1325
1336
  }
1326
1337
 
@@ -1488,17 +1499,24 @@ export class InteractiveMode {
1488
1499
  const level = this.session.thinkingLevel || "off";
1489
1500
  this.editor.borderColor = theme.getThinkingBorderColor(level);
1490
1501
  }
1502
+ // Update footer content in editor's top border
1503
+ this.updateEditorTopBorder();
1491
1504
  this.ui.requestRender();
1492
1505
  }
1493
1506
 
1507
+ private updateEditorTopBorder(): void {
1508
+ const width = this.ui.getWidth();
1509
+ const topBorder = this.statusLine.getTopBorder(width);
1510
+ this.editor.setTopBorder(topBorder);
1511
+ }
1512
+
1494
1513
  private cycleThinkingLevel(): void {
1495
1514
  const newLevel = this.session.cycleThinkingLevel();
1496
1515
  if (newLevel === undefined) {
1497
1516
  this.showStatus("Current model does not support thinking");
1498
1517
  } else {
1499
- this.footer.invalidate();
1518
+ this.statusLine.invalidate();
1500
1519
  this.updateEditorBorderColor();
1501
- this.showStatus(`Thinking level: ${newLevel}`);
1502
1520
  }
1503
1521
  }
1504
1522
 
@@ -1509,7 +1527,7 @@ export class InteractiveMode {
1509
1527
  const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
1510
1528
  this.showStatus(msg);
1511
1529
  } else {
1512
- this.footer.invalidate();
1530
+ this.statusLine.invalidate();
1513
1531
  this.updateEditorBorderColor();
1514
1532
  const thinkingStr =
1515
1533
  result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
@@ -1635,42 +1653,6 @@ export class InteractiveMode {
1635
1653
  this.ui.requestRender();
1636
1654
  }
1637
1655
 
1638
- /**
1639
- * Generate a title for the session based on the first user message.
1640
- * Runs in background, doesn't block UI.
1641
- */
1642
- private maybeGenerateTitle(): void {
1643
- // Find the first user message
1644
- const messages = this.agent.state.messages;
1645
- const firstUserMessage = messages.find((m) => m.role === "user");
1646
- if (!firstUserMessage) return;
1647
-
1648
- // Extract text content
1649
- let messageText = "";
1650
- for (const content of firstUserMessage.content) {
1651
- if (typeof content === "string") {
1652
- messageText += content;
1653
- } else if (content.type === "text") {
1654
- messageText += content.text;
1655
- }
1656
- }
1657
- if (!messageText.trim()) return;
1658
-
1659
- // Generate title in background
1660
- const registry = this.session.modelRegistry;
1661
- const smolModel = this.settingsManager.getModelRole("smol");
1662
- generateSessionTitle(messageText, registry, smolModel)
1663
- .then((title) => {
1664
- if (title) {
1665
- this.sessionManager.setSessionTitle(title);
1666
- setTerminalTitle(`omp: ${title}`);
1667
- }
1668
- })
1669
- .catch(() => {
1670
- // Errors logged via logger in title-generator
1671
- });
1672
- }
1673
-
1674
1656
  private updatePendingMessagesDisplay(): void {
1675
1657
  this.pendingMessagesContainer.clear();
1676
1658
  const queuedMessages = this.session.getQueuedMessages();
@@ -1781,7 +1763,7 @@ export class InteractiveMode {
1781
1763
  // Session-managed settings (not in SettingsManager)
1782
1764
  case "autoCompact":
1783
1765
  this.session.setAutoCompactionEnabled(value as boolean);
1784
- this.footer.setAutoCompactEnabled(value as boolean);
1766
+ this.statusLine.setAutoCompactEnabled(value as boolean);
1785
1767
  break;
1786
1768
  case "queueMode":
1787
1769
  this.session.setQueueMode(value as "all" | "one-at-a-time");
@@ -1791,7 +1773,7 @@ export class InteractiveMode {
1791
1773
  break;
1792
1774
  case "thinkingLevel":
1793
1775
  this.session.setThinkingLevel(value as ThinkingLevel);
1794
- this.footer.invalidate();
1776
+ this.statusLine.invalidate();
1795
1777
  this.updateEditorBorderColor();
1796
1778
  break;
1797
1779
 
@@ -1840,7 +1822,7 @@ export class InteractiveMode {
1840
1822
  // Only update agent state for default role
1841
1823
  if (role === "default") {
1842
1824
  await this.session.setModel(model, role);
1843
- this.footer.invalidate();
1825
+ this.statusLine.invalidate();
1844
1826
  this.updateEditorBorderColor();
1845
1827
  }
1846
1828
  // For other roles (small), just show status - settings already updated by selector
@@ -2076,13 +2058,7 @@ export class InteractiveMode {
2076
2058
  }
2077
2059
  this.ui.requestRender();
2078
2060
 
2079
- const openCmd =
2080
- process.platform === "darwin"
2081
- ? "open"
2082
- : process.platform === "win32"
2083
- ? "start"
2084
- : "xdg-open";
2085
- Bun.spawn([openCmd, info.url], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
2061
+ this.openInBrowser(info.url);
2086
2062
  },
2087
2063
  onPrompt: async (prompt: { message: string; placeholder?: string }) => {
2088
2064
  this.chatContainer.addChild(new Spacer(1));
@@ -2156,6 +2132,11 @@ export class InteractiveMode {
2156
2132
  // Command handlers
2157
2133
  // =========================================================================
2158
2134
 
2135
+ private openInBrowser(urlOrPath: string): void {
2136
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2137
+ Bun.spawn([openCmd, urlOrPath], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
2138
+ }
2139
+
2159
2140
  private async handleExportCommand(text: string): Promise<void> {
2160
2141
  const parts = text.split(/\s+/);
2161
2142
  const arg = parts.length > 1 ? parts[1] : undefined;
@@ -2180,6 +2161,7 @@ export class InteractiveMode {
2180
2161
  try {
2181
2162
  const filePath = await this.session.exportToHtml(arg);
2182
2163
  this.showStatus(`Session exported to: ${filePath}`);
2164
+ this.openInBrowser(filePath);
2183
2165
  } catch (error: unknown) {
2184
2166
  this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
2185
2167
  }
@@ -2293,6 +2275,7 @@ export class InteractiveMode {
2293
2275
  // Create the preview URL
2294
2276
  const previewUrl = `https://gistpreview.github.io/?${gistId}`;
2295
2277
  this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
2278
+ this.openInBrowser(previewUrl);
2296
2279
  } catch (error: unknown) {
2297
2280
  if (!loader.signal.aborted) {
2298
2281
  restoreEditor();
@@ -2564,7 +2547,8 @@ export class InteractiveMode {
2564
2547
  const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString());
2565
2548
  this.addMessageToChat(msg);
2566
2549
 
2567
- this.footer.invalidate();
2550
+ this.statusLine.invalidate();
2551
+ this.updateEditorTopBorder();
2568
2552
  } catch (error) {
2569
2553
  const message = error instanceof Error ? error.message : String(error);
2570
2554
  if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
@@ -2584,7 +2568,7 @@ export class InteractiveMode {
2584
2568
  this.loadingAnimation.stop();
2585
2569
  this.loadingAnimation = undefined;
2586
2570
  }
2587
- this.footer.dispose();
2571
+ this.statusLine.dispose();
2588
2572
  if (this.unsubscribe) {
2589
2573
  this.unsubscribe();
2590
2574
  }