@oh-my-pi/pi-tui 18.4.12 → 18.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.
@@ -249,6 +249,11 @@ export class PlanReviewOverlay implements Component {
249
249
  #nativeRoot: NativeNode | undefined;
250
250
  /** Content the memoized root was built from. */
251
251
  #nativeRootContent: NativePlanContent | undefined;
252
+ /**
253
+ * The section the last Contents jump targets under a native surface, which
254
+ * scrolls the body itself: `n` counts the jumps, so each one reveals it again.
255
+ */
256
+ #jump: { section: number; n: number } | undefined;
252
257
 
253
258
  constructor(
254
259
  planContent: string,
@@ -798,6 +803,7 @@ export class PlanReviewOverlay implements Component {
798
803
  #scrubBodyToToc(): void {
799
804
  const sectionIndex = this.#toc[this.#tocCursor];
800
805
  if (sectionIndex === undefined) return;
806
+ this.#jump = { section: sectionIndex, n: (this.#jump?.n ?? 0) + 1 };
801
807
  const offset = this.#sectionOffsets[sectionIndex];
802
808
  if (offset !== undefined) {
803
809
  this.#scrollView.setScrollOffset(offset);
@@ -1404,6 +1410,7 @@ export class PlanReviewOverlay implements Component {
1404
1410
  const sig = [
1405
1411
  this.#focus,
1406
1412
  this.#tocCursor,
1413
+ this.#jump?.n ?? 0,
1407
1414
  this.#selectedIndex,
1408
1415
  this.#sliderIndex,
1409
1416
  this.#committed ? `committed:${this.#committedLabel ?? ""}` : "",
@@ -1419,10 +1426,17 @@ export class PlanReviewOverlay implements Component {
1419
1426
  const children: NativeChild[] = [];
1420
1427
  const tools = this.#describeTools();
1421
1428
  if (tools) children.push(tools);
1429
+ // The section a Contents jump targets scrolls to the body's top.
1430
+ const jump = this.#jump;
1431
+ const body = jump
1432
+ ? content.body.map((section): NativeNode =>
1433
+ section.key === `s${jump.section}` ? { ...section, reveal: { at: "start", n: jump.n } } : section,
1434
+ )
1435
+ : content.body;
1422
1436
  const bodyCol = node(
1423
1437
  "col",
1424
1438
  { role: "omp.plan.body", grow: 1, gap: "md", tone: this.#focus === "body" ? "accent" : undefined },
1425
- content.body,
1439
+ body,
1426
1440
  "body",
1427
1441
  );
1428
1442
  if (this.#sidebarShown) {
@@ -0,0 +1,191 @@
1
+ import type { TspScrollBy, TspText } from "@oh-my-pi/pi-wire";
2
+ import { OverlayPanel } from "../chrome/overlay-box";
3
+ import { editorKey } from "../chrome/keybinding-hints";
4
+ import { Ellipsis } from "../index";
5
+ import { ScrollView } from "../components/scroll-view";
6
+ import { Spacer } from "../components/spacer";
7
+ import { Text } from "../components/text";
8
+ import {
9
+ matchesSelectCancel,
10
+ matchesSelectDown,
11
+ matchesSelectPageDown,
12
+ matchesSelectPageUp,
13
+ matchesSelectUp,
14
+ } from "../keybinding-matchers";
15
+ import { matchesKey } from "../keys";
16
+ import { routeSgrMouseInput } from "../mouse";
17
+ import { actionBar, actionButton } from "../native/overlay";
18
+ import { col } from "../native/describe";
19
+ import type { NativeNode, NativeScroll, NativeUiEvent } from "../native/node";
20
+ import { theme } from "../theme/theme";
21
+ import type { Component } from "../tui";
22
+
23
+ /** Options of a {@link ReportPanel}. */
24
+ export interface ReportPanelOptions {
25
+ /** Plain title: the text-mode border title, and the native sheet head unless {@link head} is given. */
26
+ readonly title: string;
27
+ /** Styled native sheet head. */
28
+ readonly head?: TspText;
29
+ /** The read-only report; it draws no frame or title of its own. */
30
+ readonly body: Component;
31
+ /**
32
+ * Display form of the key that dismisses the report while it sits above the
33
+ * editor (the editor's interrupt key, which routes to it). Once the panel
34
+ * holds focus ({@link ReportPanel.holdFocus}) the hint shows the key its own
35
+ * input closes on instead.
36
+ */
37
+ readonly closeKey: string;
38
+ /** The focused panel (native sheet, text-mode full-screen page) closes on Esc / its Close button: this runs. */
39
+ readonly onClose: () => void;
40
+ /**
41
+ * Most rows the text-mode panel may take, borders included (the screen rows
42
+ * left above the editor); a taller report scrolls inside it. `undefined`
43
+ * leaves it uncapped.
44
+ */
45
+ readonly maxRows?: () => number | undefined;
46
+ }
47
+
48
+ /** Text-mode rows around the scrolled body: the borders, two spacers and the footer. */
49
+ const TEXT_CHROME_ROWS = 5;
50
+
51
+ /**
52
+ * A read-only command report (`/changelog`, `/context`, `/tools`, …).
53
+ *
54
+ * Text mode draws a titled box with the body and an Esc hint: above the editor
55
+ * like the `/btw` panel while it fits there (the editor keeps focus and its
56
+ * Esc takes it away), or as a full-screen page on the alternate screen when it
57
+ * is taller, whose body scrolls on the arrow/page/Home/End keys and the wheel.
58
+ * Natively it is a sheet like `/usage`: a centred `lg` glass sheet titled by
59
+ * the report whose body the terminal scrolls (wheel, and the same keys
60
+ * forwarded as scroll requests), then a Close button; Esc closes it.
61
+ */
62
+ export class ReportPanel extends OverlayPanel {
63
+ readonly nativeOverlay: NonNullable<Component["nativeOverlay"]>;
64
+ readonly #body: Component;
65
+ readonly #onClose: () => void;
66
+ readonly #maxRows: (() => number | undefined) | undefined;
67
+ readonly #view: ScrollView;
68
+ readonly #footer: Text;
69
+ #closeHint: string;
70
+ #bodyRows = 0;
71
+ #scroll: NativeScroll | undefined;
72
+ #native: { scroll: NativeScroll | undefined; node: NativeNode } | undefined;
73
+
74
+ constructor(options: ReportPanelOptions) {
75
+ super(options.title, "omp.overlay.report");
76
+ this.nativeOverlay = {
77
+ role: "omp.overlay.report",
78
+ size: "lg",
79
+ anchor: "center",
80
+ head: options.head ?? options.title,
81
+ };
82
+ this.#body = options.body;
83
+ this.#onClose = options.onClose;
84
+ this.#maxRows = options.maxRows;
85
+ this.#closeHint = `${options.closeKey} to close`;
86
+ this.#view = new ScrollView(options.body, {
87
+ height: 0,
88
+ scrollbar: "auto",
89
+ ellipsis: Ellipsis.Omit,
90
+ theme: { track: text => theme.fg("dim", text), thumb: text => theme.fg("accent", text) },
91
+ });
92
+ this.#footer = new Text(theme.fg("muted", this.#closeHint), 0, 0);
93
+ this.addChild(new Spacer(1));
94
+ this.addChild(this.#view);
95
+ this.addChild(new Spacer(1));
96
+ this.addChild(this.#footer);
97
+ }
98
+
99
+ /**
100
+ * The panel is shown with focus (native sheet, text-mode full-screen page):
101
+ * its own {@link handleInput} closes it, so the hint names that key.
102
+ */
103
+ holdFocus(): void {
104
+ this.#closeHint = `${editorKey("tui.select.cancel")} to close`;
105
+ }
106
+
107
+ /**
108
+ * Text mode: cap the box to {@link ReportPanelOptions.maxRows}, scrolling a
109
+ * taller body. The cap is a hard limit: when a resize leaves fewer rows than
110
+ * the box's chrome needs, its bottom (the hint and closing border) is cut
111
+ * rather than spilling over the editor.
112
+ */
113
+ override render(width: number): readonly string[] {
114
+ const innerWidth = Math.max(1, width - 4);
115
+ const rows = this.#body.render(innerWidth).length;
116
+ const cap = this.#maxRows?.();
117
+ const bodyMax = cap === undefined ? rows : Math.max(1, cap - TEXT_CHROME_ROWS);
118
+ const overflows = rows > bodyMax;
119
+ this.#bodyRows = Math.min(rows, bodyMax);
120
+ this.#view.setHeight(this.#bodyRows);
121
+ const scrollHint = `${editorKey("tui.select.pageUp")}/${editorKey("tui.select.pageDown")} scroll · `;
122
+ this.#footer.setText(theme.fg("muted", overflows ? scrollHint + this.#closeHint : this.#closeHint));
123
+ const lines = super.render(width);
124
+ return cap !== undefined && lines.length > cap ? lines.slice(0, Math.max(0, cap)) : lines;
125
+ }
126
+
127
+ /** Text mode: rows the whole box takes at `width` when nothing caps it. */
128
+ heightAt(width: number): number {
129
+ return this.#body.render(Math.max(1, width - 4)).length + TEXT_CHROME_ROWS;
130
+ }
131
+
132
+ /** The sheet body (the overlay is the frame, and its body the scroller): the report, then Close (Esc). */
133
+ override describe(): NativeNode {
134
+ const scroll = this.#scroll;
135
+ if (this.#native && this.#native.scroll === scroll) return this.#native.node;
136
+ const body: NativeNode = { ...col([this.#body]), key: "body", scroll };
137
+ const node = col([body, actionBar([null, actionButton("Close", "close", { keys: "escape" })])], { gap: "md" });
138
+ this.#native = { scroll, node };
139
+ return node;
140
+ }
141
+
142
+ override invalidate(): void {
143
+ this.#native = undefined;
144
+ super.invalidate();
145
+ }
146
+
147
+ /**
148
+ * Focused (the native sheet, or the text-mode full-screen page): Esc closes;
149
+ * the arrow, page, Home and End keys and the wheel scroll the body.
150
+ */
151
+ handleInput(data: string): void {
152
+ if (
153
+ routeSgrMouseInput(data, event => {
154
+ if (event.wheel === null) return false;
155
+ this.#view.scroll(event.wheel * 3);
156
+ return true;
157
+ })
158
+ ) {
159
+ return;
160
+ }
161
+ if (matchesSelectCancel(data)) {
162
+ this.#onClose();
163
+ return;
164
+ }
165
+ const by: TspScrollBy | undefined = matchesSelectUp(data)
166
+ ? "line-up"
167
+ : matchesSelectDown(data)
168
+ ? "line-down"
169
+ : matchesSelectPageUp(data)
170
+ ? "page-up"
171
+ : matchesSelectPageDown(data)
172
+ ? "page-down"
173
+ : matchesKey(data, "home")
174
+ ? "start"
175
+ : matchesKey(data, "end")
176
+ ? "end"
177
+ : undefined;
178
+ if (by === undefined) return;
179
+ // Natively the terminal scrolls the sheet body; text mode scrolls the box.
180
+ this.#scroll = { by, n: (this.#scroll?.n ?? 0) + 1 };
181
+ const page = Math.max(1, this.#bodyRows - 1);
182
+ if (by === "start") this.#view.scrollToTop();
183
+ else if (by === "end") this.#view.scrollToBottom();
184
+ else this.#view.scroll(by === "line-up" ? -1 : by === "line-down" ? 1 : by === "page-up" ? -page : page);
185
+ }
186
+
187
+ /** Close runs what Esc runs. */
188
+ handleNativeEvent(event: NativeUiEvent): void {
189
+ if (event.type === "action" && event.act === "close") this.#onClose();
190
+ }
191
+ }
@@ -1,7 +1,6 @@
1
- import type { Effort } from "@oh-my-pi/pi-ai";
1
+ import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../thinking";
2
2
  import { type SelectItem, SelectList, type SgrMouseEvent } from "../index";
3
3
  import { getSelectListTheme } from "../theme/theme";
4
- import { getThinkingLevelMetadata } from "../thinking";
5
4
  import { OverlayPanel } from "../chrome/overlay-box";
6
5
  import { routeSelectListMouseWithTopBorder } from "../chrome/select-list-mouse-routing";
7
6
  import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node";
@@ -15,14 +14,14 @@ export class ThinkingSelectorComponent extends OverlayPanel {
15
14
  #sheet: SelectListSheet;
16
15
 
17
16
  constructor(
18
- currentLevel: Effort,
19
- availableLevels: Effort[],
20
- onSelect: (level: Effort) => void,
17
+ currentLevel: ConfiguredThinkingLevel | undefined,
18
+ availableLevels: ConfiguredThinkingLevel[],
19
+ onSelect: (level: ConfiguredThinkingLevel) => void,
21
20
  onCancel: () => void,
22
21
  ) {
23
22
  super("Thinking Level", "omp.overlay.thinking");
24
23
 
25
- const thinkingLevels: SelectItem[] = availableLevels.map(getThinkingLevelMetadata);
24
+ const thinkingLevels: SelectItem[] = availableLevels.map(getConfiguredThinkingLevelMetadata);
26
25
 
27
26
  // Create selector
28
27
  this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
@@ -34,7 +33,7 @@ export class ThinkingSelectorComponent extends OverlayPanel {
34
33
  }
35
34
 
36
35
  this.#selectList.onSelect = item => {
37
- onSelect(item.value as Effort);
36
+ onSelect(item.value as ConfiguredThinkingLevel);
38
37
  };
39
38
 
40
39
  this.#selectList.onCancel = () => {
@@ -46,10 +45,11 @@ export class ThinkingSelectorComponent extends OverlayPanel {
46
45
  title: "Thinking level",
47
46
  icon: "brain",
48
47
  noun: "levels",
49
- current: [currentLevel],
50
- decorate: item => ({
51
- chips: [{ text: "", dot: `thinking${item.value.charAt(0).toUpperCase()}${item.value.slice(1)}` }],
52
- }),
48
+ current: currentLevel ? [currentLevel] : [],
49
+ decorate: item =>
50
+ item.value === AUTO_THINKING
51
+ ? {}
52
+ : { chips: [{ text: "", dot: `thinking${item.value.charAt(0).toUpperCase()}${item.value.slice(1)}` }] },
53
53
  });
54
54
  }
55
55
 
@@ -3,11 +3,10 @@
3
3
  * (`~/.omp/agent/cache/composer.db`).
4
4
  *
5
5
  * Each row is one JSON payload keyed by project (the resolved cwd) and kind.
6
- * Settings-derived kinds (theme/composer preferences, welcome model labels,
7
- * status-bar inputs) are also written under the empty project, so a folder that
8
- * never ran omp still paints with the user's theme and status bar: those are
9
- * rarely project-specific, and path/branch render live. Recent sessions and LSP
10
- * rows stay per project.
6
+ * Settings-derived kinds (theme/composer preferences, status-bar inputs) are
7
+ * also written under the empty project, so a folder that never ran omp still
8
+ * paints with the user's theme and status bar: those are rarely
9
+ * project-specific, and path/branch render live.
11
10
  *
12
11
  * ```text
13
12
  * entries (project TEXT, kind TEXT, value TEXT JSON, PRIMARY KEY (project, kind))
@@ -25,7 +24,6 @@ import * as logger from "@oh-my-pi/pi-utils/logger";
25
24
  import * as postmortem from "@oh-my-pi/pi-utils/postmortem";
26
25
  import { openSqliteDatabaseSync } from "@oh-my-pi/pi-utils/sqlite";
27
26
  import { isRecord } from "@oh-my-pi/pi-utils/type-guards";
28
- import type { LspServerInfo, RecentSession } from "./welcome";
29
27
  import type { ComposerPreferences, ComposerStatusCache } from "./composer";
30
28
  import { readStatusLineStartupData } from "../status-line/startup";
31
29
  import type { SymbolPreset } from "../theme/theme";
@@ -35,7 +33,6 @@ import { isWordCompletionMethod } from "./word-completion";
35
33
  const FORMAT_VERSION = 1;
36
34
  /** Project key of rows that serve every project lacking its own. */
37
35
  const ANY_PROJECT = "";
38
- const RECENT_SESSION_LIMIT = 4;
39
36
 
40
37
  const SCHEMA = `
41
38
  PRAGMA journal_mode=WAL;
@@ -48,9 +45,8 @@ CREATE TABLE IF NOT EXISTS entries (
48
45
  ) WITHOUT ROWID;
49
46
  `;
50
47
 
51
- type EntryKind = "ui" | "welcome" | "recent-sessions" | "lsp-servers" | "status";
52
- /** Kinds mirrored under {@link ANY_PROJECT} as the fallback for projects without their own row. */
53
- type SharedEntryKind = "ui" | "welcome" | "status";
48
+ /** Every kind is mirrored under {@link ANY_PROJECT} as the fallback for projects without their own row. */
49
+ type EntryKind = "ui" | "status";
54
50
 
55
51
  /** Theme inputs cached from the last resolved settings load for stable prepaint colors. */
56
52
  export interface ComposerThemePreferences {
@@ -60,20 +56,10 @@ export interface ComposerThemePreferences {
60
56
  readonly lightTheme?: string;
61
57
  }
62
58
 
63
- /** Last authoritative model labels shown in the welcome component. */
64
- export interface ComposerWelcomeCache {
65
- readonly modelName: string;
66
- readonly providerName: string;
67
- }
68
-
69
59
  /** Speculative composer state read before the settings/session graph is available. */
70
60
  export interface ComposerStartupCache {
71
61
  readonly preferences?: ComposerPreferences;
72
62
  readonly theme?: ComposerThemePreferences;
73
- readonly welcome?: ComposerWelcomeCache;
74
- readonly recentSessions: RecentSession[];
75
- /** `null` when the last run had LSP disabled. */
76
- readonly lspServers: LspServerInfo[] | null;
77
63
  readonly status?: ComposerStatusCache;
78
64
  }
79
65
 
@@ -86,44 +72,6 @@ function parseJson(value: string | undefined): unknown {
86
72
  }
87
73
  }
88
74
 
89
- function parseRecentSessions(value: unknown): RecentSession[] {
90
- if (!Array.isArray(value)) return [];
91
- const sessions: RecentSession[] = [];
92
- for (const item of value) {
93
- if (!isRecord(item)) continue;
94
- const { name, timeAgo } = item;
95
- if (typeof name === "string" && typeof timeAgo === "string") sessions.push({ name, timeAgo });
96
- if (sessions.length === RECENT_SESSION_LIMIT) break;
97
- }
98
- return sessions;
99
- }
100
-
101
- function parseLspServers(value: unknown): LspServerInfo[] | null {
102
- if (value === null) return null;
103
- if (!Array.isArray(value)) return [];
104
- const servers: LspServerInfo[] = [];
105
- for (const item of value) {
106
- if (!isRecord(item)) continue;
107
- const { name, status, fileTypes } = item;
108
- if (
109
- typeof name !== "string" ||
110
- (status !== "ready" && status !== "error" && status !== "connecting" && status !== "available") ||
111
- !Array.isArray(fileTypes) ||
112
- !fileTypes.every(fileType => typeof fileType === "string")
113
- ) {
114
- continue;
115
- }
116
- servers.push({ name, status, fileTypes });
117
- }
118
- return servers;
119
- }
120
-
121
- function parseWelcome(value: unknown): ComposerWelcomeCache | undefined {
122
- if (!isRecord(value)) return undefined;
123
- const { modelName, providerName } = value;
124
- return typeof modelName === "string" && typeof providerName === "string" ? { modelName, providerName } : undefined;
125
- }
126
-
127
75
  function parseStatus(value: unknown): ComposerStatusCache | undefined {
128
76
  if (!isRecord(value)) return undefined;
129
77
  const statusLine = readStatusLineStartupData(value.statusLine);
@@ -275,9 +223,6 @@ export class ComposerCache {
275
223
  return {
276
224
  preferences: ui?.preferences,
277
225
  theme: ui?.theme,
278
- welcome: parseWelcome(parseJson(own.welcome)) ?? parseWelcome(parseJson(anyProject.welcome)),
279
- recentSessions: parseRecentSessions(parseJson(own["recent-sessions"])),
280
- lspServers: own["lsp-servers"] === undefined ? [] : parseLspServers(parseJson(own["lsp-servers"])),
281
226
  status: parseStatus(parseJson(own.status)) ?? parseStatus(parseJson(anyProject.status)),
282
227
  };
283
228
  }
@@ -287,21 +232,6 @@ export class ComposerCache {
287
232
  this.#putShared(cwd, "ui", { preferences, theme });
288
233
  }
289
234
 
290
- /** Authoritative model/provider labels for the next welcome prepaint. */
291
- writeWelcome(cwd: string, welcome: ComposerWelcomeCache): void {
292
- this.#putShared(cwd, "welcome", welcome);
293
- }
294
-
295
- /** The latest recent-session rows (first four). */
296
- writeRecentSessions(cwd: string, sessions: readonly RecentSession[]): void {
297
- this.#put(cwd, "recent-sessions", sessions.slice(0, RECENT_SESSION_LIMIT));
298
- }
299
-
300
- /** The latest detected project LSP rows; `null` records that LSP is disabled. */
301
- writeLspServers(cwd: string, servers: readonly LspServerInfo[] | null): void {
302
- this.#put(cwd, "lsp-servers", servers);
303
- }
304
-
305
235
  /** Status-bar inputs for the next prepaint's startup status line. */
306
236
  writeStatus(cwd: string, status: ComposerStatusCache): void {
307
237
  this.#putShared(cwd, "status", status);
@@ -314,22 +244,11 @@ export class ComposerCache {
314
244
  this.#db.close();
315
245
  }
316
246
 
317
- /** Best-effort upsert: a failed write only costs the next launch its speculation. */
318
- #put(cwd: string, kind: EntryKind, value: unknown): void {
319
- const project = path.resolve(cwd);
320
- const json = JSON.stringify(value);
321
- const key = `${project}\0${kind}`;
322
- if (this.#known.get(key) === json) return;
323
- try {
324
- this.#upsert.run(project, kind, json);
325
- this.#known.set(key, json);
326
- } catch (error) {
327
- logger.debug("composer cache write failed", { kind, error: String(error) });
328
- }
329
- }
330
-
331
- /** {@link #put} for this project plus the any-project fallback row, atomically. */
332
- #putShared(cwd: string, kind: SharedEntryKind, value: unknown): void {
247
+ /**
248
+ * Best-effort upsert of this project's row plus the any-project fallback row,
249
+ * atomically: a failed write only costs the next launch its speculation.
250
+ */
251
+ #putShared(cwd: string, kind: EntryKind, value: unknown): void {
333
252
  const project = path.resolve(cwd);
334
253
  const json = JSON.stringify(value);
335
254
  const ownKey = `${project}\0${kind}`;
@@ -22,7 +22,7 @@ import { postmortem } from "@oh-my-pi/pi-utils";
22
22
  import { CustomEditor } from "./custom-editor";
23
23
  import type { WordCompletionMethod } from "./word-completion";
24
24
  import { type AnimationFrame, TranscriptContainer } from "../chrome/transcript-container";
25
- import { type LspServerInfo, type RecentSession, WelcomeComponent } from "./welcome";
25
+ import { WelcomeComponent } from "./welcome";
26
26
  import { ensureThemeSync, getEditorTheme, theme } from "../theme/theme";
27
27
 
28
28
  const DOUBLE_INTERRUPT_MS = 500;
@@ -58,11 +58,6 @@ export const COMPOSER_DEFAULTS: ComposerPreferences = {
58
58
  /** Welcome data that can be supplied initially or patched as startup resolves it. */
59
59
  export interface ComposerWelcomeUpdate {
60
60
  readonly version?: string;
61
- readonly modelName?: string;
62
- readonly providerName?: string;
63
- readonly recentSessions?: readonly RecentSession[];
64
- /** Detected project servers; `null` means LSP is disabled and hides the welcome section. */
65
- readonly lspServers?: readonly LspServerInfo[] | null;
66
61
  }
67
62
 
68
63
  /**
@@ -215,10 +210,6 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
215
210
  #preferences: ComposerPreferences;
216
211
  #welcome: WelcomeComponent | undefined;
217
212
  #version = "";
218
- #modelName = "";
219
- #providerName = "";
220
- #recentSessions: RecentSession[] = [];
221
- #lspServers: LspServerInfo[] | null = [];
222
213
  #headerBefore: readonly Component[] = [];
223
214
  #headerAfter: readonly Component[] = [];
224
215
  #runtimeChildren: readonly Component[] = [];
@@ -279,6 +270,8 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
279
270
  #transientChrome: ReadonlySet<Component> = new Set();
280
271
  #transientChromeFloor: number | undefined;
281
272
  #anchorAfterInlineRetirement = false;
273
+ /** Rows the chrome below each below-transcript root took in the last frame (see {@link rowsBelow}). */
274
+ #rowsBelow = new Map<Component, number>();
282
275
  #lastInterruptAt = 0;
283
276
  /** Last described surface; its arrays are reused while their children are unchanged. */
284
277
  #nativeSurface: NativeSurface = { main: [], dock: [] };
@@ -347,6 +340,27 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
347
340
  this.ui.addChild(this.#statusHost);
348
341
  this.ui.setFocus(this.editor);
349
342
  }
343
+ /**
344
+ * Rows the below-transcript chrome under `root` (editor, status line, …)
345
+ * took in the last frame, so a root that grows upward can cap itself to
346
+ * the screen rows left above them; `undefined` before `root` was laid out.
347
+ */
348
+ rowsBelow(root: Component): number | undefined {
349
+ return this.#rowsBelow.get(root);
350
+ }
351
+
352
+ /**
353
+ * Keep the input on the bottom row while the live rows cannot fill the
354
+ * screen, as after an inline decision panel closes. A tall block that just
355
+ * left the chrome above the editor (a command report) may have scrolled
356
+ * rows into native history that cannot be pulled back; without the pin the
357
+ * editor would jump up to where the shorter frame now ends. The pin lifts
358
+ * once live rows fill the screen again.
359
+ */
360
+ pinInputToBottom(): void {
361
+ this.#anchorAfterInlineRetirement = true;
362
+ }
363
+
350
364
  /** Compose the bounded mutable viewport and the next ordered history append. */
351
365
  renderFrame(viewport: ViewportSize): TerminalFramePlan {
352
366
  if (!this.#started || this.#stopped) return { viewport: [] };
@@ -373,10 +387,12 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
373
387
  let transientRows = 0;
374
388
  let displacingRows = 0;
375
389
  let decisionPanelOpen = false;
390
+ const ends: { root: Component; end: number }[] = [];
376
391
  for (const root of afterRoots) {
377
392
  const chrome: Component = root;
378
393
  const start = after.length;
379
394
  this.#renderBelowRoot(root, width, after, afterSpans);
395
+ ends.push({ root, end: after.length });
380
396
  if (this.#transientChrome.has(root)) transientRows += after.length - start;
381
397
  if (
382
398
  chrome.retireDisplacedTranscript ||
@@ -386,6 +402,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
386
402
  decisionPanelOpen = true;
387
403
  }
388
404
  }
405
+ this.#rowsBelow = new Map(ends.map(({ root, end }) => [root, after.length - end]));
389
406
  // Offer history under capacity pressure only: blocks stay live (and keep
390
407
  // reflowing to the current width) while the screen has room. A batch
391
408
  // leaves the mutable viewport in the same frame it is appended, so its
@@ -871,7 +888,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
871
888
  this.ui.requestRender();
872
889
  }
873
890
 
874
- /** Patch welcome data in place as model, session, and project discovery complete. */
891
+ /** Patch welcome data in place as version, session, and project discovery complete. */
875
892
  updateWelcome(update: ComposerWelcomeUpdate): void {
876
893
  if (this.#stopped) return;
877
894
  this.#applyWelcomeUpdate(update);
@@ -880,11 +897,6 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
880
897
  const welcome = this.#welcome;
881
898
  if (!welcome) return;
882
899
  if (update.version !== undefined) welcome.setVersion(this.#version);
883
- if (update.modelName !== undefined || update.providerName !== undefined) {
884
- welcome.setModel(this.#modelName, this.#providerName);
885
- }
886
- if (update.recentSessions !== undefined) welcome.setRecentSessions(this.#recentSessions);
887
- if (update.lspServers !== undefined) welcome.setLspServers(this.#lspServers);
888
900
  this.ui.requestRender();
889
901
  }
890
902
 
@@ -965,20 +977,10 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
965
977
 
966
978
  #applyWelcomeUpdate(update: ComposerWelcomeUpdate): void {
967
979
  if (update.version !== undefined) this.#version = update.version;
968
- if (update.modelName !== undefined) this.#modelName = update.modelName;
969
- if (update.providerName !== undefined) this.#providerName = update.providerName;
970
- if (update.recentSessions !== undefined) this.#recentSessions = [...update.recentSessions];
971
- if (update.lspServers !== undefined) this.#lspServers = update.lspServers && [...update.lspServers];
972
980
  }
973
981
 
974
982
  #ensureWelcome(): void {
975
- this.#welcome ??= new WelcomeComponent(
976
- this.#version,
977
- this.#modelName,
978
- this.#providerName,
979
- this.#recentSessions,
980
- this.#lspServers,
981
- );
983
+ this.#welcome ??= new WelcomeComponent(this.#version);
982
984
  }
983
985
 
984
986
  #rebuildHeader(): void {
@@ -197,8 +197,14 @@ function normalizePastedPath(path: string): string {
197
197
  try {
198
198
  return url.fileURLToPath(unquoted);
199
199
  } catch {
200
- // Malformed file URL: drop through to the shell-unescape branch
201
- // so the caller can still reject it as a non-explicit path.
200
+ // Windows rejects drive-less URLs (`file:///Users/…`, forwarded from a
201
+ // macOS pasteboard or a remote session); decode them as POSIX paths.
202
+ try {
203
+ return url.fileURLToPath(unquoted, { windows: false });
204
+ } catch {
205
+ // Malformed file URL: drop through to the shell-unescape branch
206
+ // so the caller can still reject it as a non-explicit path.
207
+ }
202
208
  }
203
209
  }
204
210
  return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
@@ -1,4 +1,8 @@
1
1
  Tired of typing "keep going"? Just send a '.'
2
+ Type `#` anywhere in your prompt for quick actions — copy the line or the whole prompt, undo, jump the cursor
3
+ Type `/` at the start of your prompt to browse every command
4
+ Start your prompt with `!` to run it as bash — `!!` runs it without adding the output to context
5
+ Start your prompt with `$ ` to run Python — `$$ ` runs it without adding the output to context
2
6
  You can /btw to ask a side question
3
7
  Use /tan to fork the current conversation into a background agent
4
8
  With default keybindings, {key:ctrl+d} deletes the character under the cursor instead of quitting your draft — see /hotkeys for your exit key