@oh-my-pi/pi-tui 18.11.0 → 18.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +52 -146
  2. package/dist/types/apps/git/git-tui.d.ts +16 -2
  3. package/dist/types/autocomplete.d.ts +2 -0
  4. package/dist/types/charts/chart-svg.d.ts +7 -13
  5. package/dist/types/chat/table-chart.d.ts +11 -15
  6. package/dist/types/chrome/transcript-container.d.ts +2 -0
  7. package/dist/types/components/image.d.ts +2 -0
  8. package/dist/types/components/markdown.d.ts +37 -0
  9. package/dist/types/native/blobs.d.ts +11 -11
  10. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  11. package/dist/types/overlays/job-tree.d.ts +50 -0
  12. package/dist/types/overlays/jobs-panel.d.ts +24 -11
  13. package/dist/types/prompt/composer.d.ts +20 -0
  14. package/dist/types/prompt/custom-editor.d.ts +2 -0
  15. package/dist/types/prompt/image-references.d.ts +22 -0
  16. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  17. package/dist/types/prompt/prose-gate.d.ts +3 -0
  18. package/dist/types/prompt/queued-messages.d.ts +1 -1
  19. package/dist/types/prompt/typing-word.d.ts +25 -0
  20. package/dist/types/render/hyperlink.d.ts +2 -0
  21. package/dist/types/render/utils.d.ts +1 -1
  22. package/dist/types/status-line/context-usage.d.ts +3 -0
  23. package/dist/types/status-line/host.d.ts +6 -4
  24. package/dist/types/status-line/startup.d.ts +6 -6
  25. package/dist/types/stdin-buffer.d.ts +7 -0
  26. package/dist/types/terminal.d.ts +8 -4
  27. package/dist/types/tools/eval.d.ts +5 -0
  28. package/dist/types/tools/grouped-file-output.d.ts +2 -0
  29. package/dist/types/tools/read.d.ts +5 -4
  30. package/dist/types/tui.d.ts +10 -0
  31. package/package.json +9 -9
  32. package/src/apps/git/git-tui.ts +27 -10
  33. package/src/apps/git/sidebar.ts +5 -1
  34. package/src/autocomplete.ts +21 -19
  35. package/src/bracketed-paste.ts +9 -2
  36. package/src/charts/chart-svg.ts +19 -22
  37. package/src/chat/assistant-message.ts +37 -12
  38. package/src/chat/bash-execution.ts +7 -2
  39. package/src/chat/image-loading.ts +4 -6
  40. package/src/chat/read-tool-group.ts +1 -0
  41. package/src/chat/table-chart.ts +24 -57
  42. package/src/chat/tool-execution.ts +11 -4
  43. package/src/chat/user-message.ts +22 -15
  44. package/src/chrome/transcript-container.ts +10 -0
  45. package/src/components/editor.ts +12 -12
  46. package/src/components/image.ts +23 -6
  47. package/src/components/markdown.ts +375 -6
  48. package/src/components/settings-list.ts +2 -4
  49. package/src/native/blobs.ts +22 -6
  50. package/src/native/reconcile.ts +19 -15
  51. package/src/overlays/agent-hub-projection.ts +63 -12
  52. package/src/overlays/btw-history-panel.ts +30 -23
  53. package/src/overlays/extensions/extension-list.ts +4 -6
  54. package/src/overlays/job-tree.ts +177 -0
  55. package/src/overlays/jobs-panel.ts +107 -49
  56. package/src/overlays/settings-selector.ts +3 -0
  57. package/src/prompt/composer.ts +180 -23
  58. package/src/prompt/custom-editor.ts +19 -1
  59. package/src/prompt/image-references.ts +79 -2
  60. package/src/prompt/macos-spelling.ts +28 -5
  61. package/src/prompt/prose-gate.ts +3 -0
  62. package/src/prompt/queued-messages.ts +9 -2
  63. package/src/prompt/typing-word.ts +59 -0
  64. package/src/render/code-cell.ts +8 -1
  65. package/src/render/hyperlink.ts +5 -0
  66. package/src/render/utils.ts +1 -1
  67. package/src/render/width-aware-text.ts +6 -5
  68. package/src/status-line/component.ts +22 -10
  69. package/src/status-line/context-usage.ts +22 -16
  70. package/src/status-line/footer.ts +18 -35
  71. package/src/status-line/host.ts +6 -1
  72. package/src/status-line/startup.ts +6 -6
  73. package/src/stdin-buffer.ts +11 -1
  74. package/src/terminal.ts +136 -42
  75. package/src/tools/ast-grep.ts +2 -2
  76. package/src/tools/edit.ts +16 -1
  77. package/src/tools/eval.ts +51 -11
  78. package/src/tools/grouped-file-output.ts +6 -1
  79. package/src/tools/read.ts +6 -5
  80. package/src/tools/write.ts +1 -1
  81. package/src/tui.ts +61 -30
  82. package/src/utils.ts +5 -3
@@ -198,6 +198,8 @@ export declare class CustomEditor extends Editor {
198
198
  * item markers use the accent color so separate follow-ups remain visible while composing.
199
199
  * Called once per layout segment, so whole-buffer inputs are computed once per text revision. */
200
200
  decorateText: (text: string, context: EditorTextDecorationContext) => string;
201
+ /** Catch the typed word up with the caret first: its leaving the word (or a blur) re-describes the typo marks. */
202
+ describe(cx: DescribeContext): NativeNode;
201
203
  /**
202
204
  * TSP decorations: magic keywords shimmer (terminal-clocked) in the accent
203
205
  * color, chips and markers take their token colors, misspellings are marked,
@@ -1,4 +1,5 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import type { TspSpan } from "@oh-my-pi/pi-wire";
2
3
  /** Materialized image destination returned by the host blob writer. */
3
4
  interface ImageBlobResult {
4
5
  displayPath: string;
@@ -23,4 +24,25 @@ type ImageBlobWriterSync = (data: Buffer, options?: {
23
24
  export declare function imageReferenceHyperlink(label: string, index: number, imageLinks: readonly (string | undefined)[] | undefined, renderLabel: (text: string) => string): string;
24
25
  export declare function materializeImageReferenceLinks(images: readonly ImageContent[] | undefined, putBlob: ImageBlobWriter): Promise<(string | undefined)[] | undefined>;
25
26
  export declare function materializeImageReferenceLinksSync(images: readonly ImageContent[] | undefined, putBlob: ImageBlobWriterSync): (string | undefined)[] | undefined;
27
+ /**
28
+ * Register the host's blob writer that backs links for images without a file of their
29
+ * own. Returns a disposer that unregisters it unless another writer replaced it since.
30
+ */
31
+ export declare function setImageBlobWriter(next: ImageBlobWriterSync): () => void;
32
+ /**
33
+ * Whether transcript images carry links under the current `tui.hyperlinks` policy: a
34
+ * native surface honours only an explicit `off`, text mode the resolved OSC 8 support.
35
+ * Renderers caching a linked image key their cache on it.
36
+ */
37
+ export declare function imageLinksEnabled(): boolean;
38
+ /**
39
+ * The file a transcript image opens: `sourcePath` when it was read from one, else a
40
+ * blob copy of its bytes written through the host. Undefined when links are disabled
41
+ * or no file exists, so a disabled policy never writes blobs.
42
+ */
43
+ export declare function transcriptImageLink(image: Pick<ImageContent, "data" | "mimeType">, sourcePath?: string): string | undefined;
44
+ /** `text` (an image's placeholder) as an OSC 8 link to the image's file; plain when hyperlinks are off or no file exists. */
45
+ export declare function linkedImageText(text: string, image: Pick<ImageContent, "data" | "mimeType">, sourcePath?: string): string;
46
+ /** Native counterpart of {@link linkedImageText}: a span linking to the image's file when one exists. */
47
+ export declare function linkedImageSpan(text: string, image: Pick<ImageContent, "data" | "mimeType">, sourcePath: string | undefined, s?: string): TspSpan;
26
48
  export {};
@@ -30,7 +30,8 @@ export declare class MacOSSpellingProvider implements EditorTextAssistProvider {
30
30
  decorateTypos(text: string, context: SpellingDecorationContext, decorate?: (span: string) => string): string;
31
31
  /**
32
32
  * Misspelled prose ranges in `text` (offsets into it), in order and
33
- * non-overlapping. Schedules the verification check for uncached text; its
33
+ * non-overlapping, except ones touching `context.typing` (the word still
34
+ * being typed). Schedules the verification check for uncached text; its
34
35
  * result arrives through {@link onUpdate}.
35
36
  */
36
37
  typoRanges(text: string, context: SpellingDecorationContext): readonly native.SpellingRange[];
@@ -1,9 +1,12 @@
1
+ import type { TypingWord } from "./typing-word.js";
1
2
  /** Logical source location for one rendered editor segment. */
2
3
  export interface SpellingDecorationContext {
3
4
  editorText: string;
4
5
  lines: readonly string[];
5
6
  line: number;
6
7
  startCol: number;
8
+ /** The word being typed: typo marks touching it wait until the caret leaves it. */
9
+ typing?: TypingWord | undefined;
7
10
  }
8
11
  /**
9
12
  * Whether `text[start, end)` is a prose word: unmasked by {@link maskNonProse},
@@ -10,7 +10,7 @@ export interface QueuedMessageGroup {
10
10
  * Messages queued while the agent works, above the editor. ANSI: a heading
11
11
  * per group, numbered dim rows and the dequeue hint. Natively (§8.1): a
12
12
  * stack of pills, each `corner-down-right` · the text · `⌥↑ Edit`, the total
13
- * count in the first pill when more than one waits; the edit control sends
13
+ * (`N queued`, not a position) in the first pill when more than one waits; the edit control sends
14
14
  * `queue.edit`, answered like the dequeue key.
15
15
  */
16
16
  export declare class QueuedMessagesBand extends Container {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The word being typed in the composer. Misspelling marks hold off on it until
3
+ * the caret leaves it (a space or punctuation, a caret move, losing focus), the
4
+ * way Slack and Chromium mark a typo only once its word is done; marks on other
5
+ * words stay while typing.
6
+ */
7
+ /** A word of one logical line, as UTF-16 columns `[start, end)`. */
8
+ export interface TypingWord {
9
+ line: number;
10
+ start: number;
11
+ end: number;
12
+ }
13
+ /** Follows an editor's text revision and caret to know which word is being typed. */
14
+ export declare class TypingWordTracker {
15
+ #private;
16
+ /** The word being typed, if any. */
17
+ get word(): TypingWord | undefined;
18
+ /**
19
+ * Catch up with the editor: an edit that leaves the caret in (or right after)
20
+ * a word starts typing it; a caret move out of that word, or losing focus,
21
+ * ends it. The first call only records the revision, so a restored draft is
22
+ * not held. Returns whether the held word changed.
23
+ */
24
+ update(revision: number, lines: readonly string[], line: number, col: number, focused: boolean): boolean;
25
+ }
@@ -70,6 +70,8 @@ export declare function urlHyperlinkAlways(url: string, displayText: string): st
70
70
  * Non-HTTP(S) or unparsable URLs yield a plain span.
71
71
  */
72
72
  export declare function urlLinkSpan(target: string, displayText: string, s?: string): TspSpan;
73
+ /** Whether native link spans and nodes may carry an `href`: everything but an explicit `off` policy. */
74
+ export declare function isNativeLinkEnabled(): boolean;
73
75
  /**
74
76
  * A native link span to a filesystem path as a plain `file://` URI (the
75
77
  * terminal opens it through the system; the location stays in the visible
@@ -32,7 +32,7 @@ export interface RenderCache {
32
32
  lines: string[];
33
33
  }
34
34
  /** Build indentation and continuing branches for ancestor levels. */
35
- export declare function buildTreePrefix(ancestors: boolean[], theme: Theme): string;
35
+ export declare function buildTreePrefix(ancestors: readonly boolean[], theme: Theme): string;
36
36
  /** Return the branch glyph for a final or continuing tree item. */
37
37
  export declare function getTreeBranch(isLast: boolean, theme: Theme): string;
38
38
  /** Return the continuation prefix for subsequent lines of a tree item. */
@@ -98,6 +98,7 @@ export interface CompactionBoundaries {
98
98
  export declare function computeCompactionBoundaries(compactionSettings: CompactionSettings, contextWindow: number, speculationLeadTokens?: number): CompactionBoundaries | null;
99
99
  /** Stable inputs used to cache non-message token estimates. */
100
100
  export interface NonMessageTokenSource {
101
+ readonly model?: Model;
101
102
  readonly systemPrompt?: readonly string[];
102
103
  readonly agent?: {
103
104
  readonly state?: {
@@ -137,6 +138,8 @@ export declare function invalidateToolSchemaMetadata(tools: ToolSchemaSource): v
137
138
  * {@link invalidateToolSchemaMetadata} when those inputs change.
138
139
  */
139
140
  export declare function estimateToolSchemaTokens(tools: ToolSchemaSource, tokenizer: Tokenizer, sourceRevision?: number): number;
141
+ /** Count schemas carried on every request, excluding server-side deferred catalogs. */
142
+ export declare function estimateContextToolSchemaTokens(tools: ToolSchemaSource, tokenizer: Tokenizer, model: Model | undefined, sourceRevision?: number): number;
140
143
  export declare function computeNonMessageTokens(session: NonMessageTokenSource, tokenizer: Tokenizer, sourceRevision?: number): number;
141
144
  /**
142
145
  * Shared helper for the four non-message token totals used by
@@ -97,11 +97,13 @@ export interface FooterSession {
97
97
  autoResolvedThinkingLevel(): string | undefined;
98
98
  getContextUsage: StatusLineSession["getContextUsage"];
99
99
  modelRegistry: Pick<StatusLineSession["modelRegistry"], "isUsingOAuth">;
100
+ /**
101
+ * Cumulative session rollup. Unlike the active-window stats it spans every
102
+ * entry in the session, so compaction does not reset the counters, and it
103
+ * folds in usage carried by completed `task` tool results.
104
+ */
100
105
  sessionManager: {
101
- getEntries(): readonly {
102
- type: string;
103
- message?: AgentMessage;
104
- }[];
106
+ getUsageStatistics: StatusLineSession["sessionManager"]["getUsageStatistics"];
105
107
  };
106
108
  }
107
109
  /**
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Status line for the speculative first frame, before any session exists.
3
3
  *
4
- * InteractiveMode persists {@link StatusLineStartupData} (settings plus the last
5
- * session's model and thinking state); the next launch renders it through the
6
- * real {@link StatusLineComponent} as a fresh, unnamed session at the live
7
- * terminal width. Path and git branch are read live; context usage is reported
8
- * unknown so the gauge shows the window without a percent.
4
+ * InteractiveMode persists {@link StatusLineStartupData} (settings plus the
5
+ * resolved default role model and status inputs); the next launch renders it
6
+ * through the real {@link StatusLineComponent} as a fresh, unnamed session at
7
+ * the live terminal width. Path and git branch are read live; context usage is
8
+ * reported unknown so the gauge shows the window without a percent.
9
9
  *
10
10
  * @example
11
11
  * ```ts ignore
@@ -22,7 +22,7 @@ import type { StatusLineSettings } from "./types.js";
22
22
  export interface StatusLineStartupData {
23
23
  readonly settings: StatusLineSettings;
24
24
  readonly gitEnabled: boolean;
25
- /** Model of the last session; the next fresh session usually starts on it too. */
25
+ /** Model resolved for the next fresh session's default role. */
26
26
  readonly model?: Model;
27
27
  readonly thinkingLevel?: ThinkingLevel;
28
28
  readonly autoThinking: boolean;
@@ -61,6 +61,13 @@ export type StdinBufferEventMap = {
61
61
  export declare class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
62
62
  #private;
63
63
  constructor(options?: StdinBufferOptions);
64
+ /**
65
+ * Whether a bracketed paste has started and its end marker has not arrived.
66
+ * Terminal skips its per-read DEC 2004 reassertion meanwhile: the host is
67
+ * evidently in bracketed-paste mode, and a paste delivered one key record
68
+ * per read would otherwise cost one terminal write per pasted key (#15757).
69
+ */
70
+ get pasting(): boolean;
64
71
  process(data: string | Buffer): void;
65
72
  /**
66
73
  * Gate unbracketed raw-paste classification on event-loop responsiveness.
@@ -117,7 +117,7 @@ export type GlyphProtocolReportHandler = (supported: boolean) => void;
117
117
  /**
118
118
  * Outcome of the Tern Surface Protocol `hello` probe: the terminal's reply,
119
119
  * or null when it answered the DA1 sentinel first (no TSP) or the probe was
120
- * skipped (`PI_TUI_NATIVE=0`, multiplexers).
120
+ * skipped (`PI_TUI_NATIVE=0`, multiplexers, devenv's reloading shell).
121
121
  */
122
122
  export type TspHelloHandler = (hello: TspHello | null) => void;
123
123
  /**
@@ -181,6 +181,7 @@ export interface Terminal {
181
181
  get kittyEnableSequence(): string | null;
182
182
  readonly keyboardEnhancementEnterSequence?: string | null;
183
183
  readonly keyboardEnhancementExitSequence?: string | null;
184
+ readonly keyboardEnhancementRestoreSequence?: string | null;
184
185
  moveBy(lines: number): void;
185
186
  hideCursor(force?: boolean): void;
186
187
  showCursor(force?: boolean): void;
@@ -283,9 +284,11 @@ export interface ProcessTerminalOptions {
283
284
  conpty?: boolean;
284
285
  /**
285
286
  * Whether stdin is a native Windows console handle (win32, not WSL), whose
286
- * console host answers `CSI ? 9001 h` with win32-input-mode key records.
287
- * Defaults to `process.platform === "win32"`; only consulted when `conpty`
288
- * is also true.
287
+ * console host is certain to answer `CSI ? 9001 h` with win32-input-mode key
288
+ * records. When false (WSL), the keyboard fallback requests modifyOtherKeys
289
+ * alongside win32-input-mode, since a WSL pty may not be relayed by a console
290
+ * host. Defaults to `process.platform === "win32"`; only consulted when
291
+ * `conpty` is also true.
289
292
  */
290
293
  nativeWindowsConsole?: boolean;
291
294
  }
@@ -298,6 +301,7 @@ export declare class ProcessTerminal implements Terminal {
298
301
  get kittyProtocolActive(): boolean;
299
302
  get kittyEnableSequence(): string | null;
300
303
  get keyboardEnhancementEnterSequence(): string | null;
304
+ get keyboardEnhancementRestoreSequence(): string | null;
301
305
  get keyboardEnhancementExitSequence(): string | null;
302
306
  get appearance(): TerminalAppearance | undefined;
303
307
  onAppearanceChange(callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void): void;
@@ -20,6 +20,11 @@ export interface EvalCellResult {
20
20
  code: string;
21
21
  language?: EvalLanguage;
22
22
  output: string;
23
+ /**
24
+ * `output` without its `display[N]:` JSON sections, which renderers show
25
+ * from `jsonOutputs` instead. Absent when the cell displayed no JSON.
26
+ */
27
+ textOutput?: string;
23
28
  status: "pending" | "running" | "complete" | "error";
24
29
  durationMs?: number;
25
30
  exitCode?: number;
@@ -111,9 +111,11 @@ export interface NumberedLine {
111
111
  * of consecutive line numbers (`start` = the run's first line, match lines
112
112
  * `mark`ed), with a `…` row wherever the numbers jump or an elision marker
113
113
  * (`n: null`) sits. No path header: the row or head above names the file.
114
+ * `wrap` soft-wraps long lines instead of scrolling the block sideways.
114
115
  */
115
116
  export declare function numberedCode(lines: readonly NumberedLine[], opts?: {
116
117
  lang?: string;
118
+ wrap?: boolean;
117
119
  }): NativeNode[];
118
120
  /**
119
121
  * Describe grouped search output (grep / ast-grep / ast-edit display content)
@@ -122,10 +122,11 @@ export declare function readSourceFsPath(details: ReadToolDetails | undefined):
122
122
  */
123
123
  export declare function readResultIsMarkdown(details: ReadToolDetails | undefined, args: ReadRenderArgs | undefined): boolean;
124
124
  /**
125
- * Read content as numbered `code` (no path header: the head names the file).
126
- * Numbers come from `lineNumbers` (`displayContent`'s per-line numbers,
127
- * `null` for an elided block; runs split with `…` between) when they cover
128
- * every line, else count up from `start`.
125
+ * Read content as numbered, wrapped `code` (no path header: the head names
126
+ * the file), matching the ANSI preview's hanging wrap. Numbers come from
127
+ * `lineNumbers` (`displayContent`'s per-line numbers, `null` for an elided
128
+ * block; runs split with `…` between) when they cover every line, else count
129
+ * up from `start`.
129
130
  */
130
131
  export declare function readContentCode(content: string, start: number, numbers: readonly (number | null)[] | undefined, lang: string | undefined): NativeNode[];
131
132
  /** Render file, image, and URL reads in the transcript. */
@@ -348,6 +348,7 @@ export declare class TUI extends Container {
348
348
  overlayStack: {
349
349
  component: Component;
350
350
  options?: OverlayOptions;
351
+ /** Focus restored after closing; tracks live focus changes beneath an input-less key holder. */
351
352
  preFocus: Component | null;
352
353
  hidden: boolean;
353
354
  /**
@@ -496,6 +497,13 @@ export declare class TUI extends Container {
496
497
  * overtaking them. No-op unless `previous` owns the held keys.
497
498
  */
498
499
  replaceHeldFocus(previous: Component, next: Component): void;
500
+ /**
501
+ * Register a hook that re-enables terminal modes on every start(). A hook
502
+ * added while the TUI is already running (a prepaint composer starts it
503
+ * before the interactive mode wires its controllers) missed that start, so
504
+ * it runs immediately instead of waiting for a stop/start cycle that may
505
+ * never come.
506
+ */
499
507
  addStartListener(listener: StartListener): () => void;
500
508
  addInputListener(listener: InputListener): () => void;
501
509
  removeInputListener(listener: InputListener): void;
@@ -508,6 +516,8 @@ export declare class TUI extends Container {
508
516
  */
509
517
  resetDisplay(): void;
510
518
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
519
+ /** Run `callback` after `delayMs` on the render scheduler's clock, unless the TUI has stopped. */
520
+ scheduleAfter(delayMs: number, callback: () => void): RenderTimer;
511
521
  /**
512
522
  * Paint a forced frame synchronously when startup must hand off an already
513
523
  * visible component tree before further async initialization. Same as
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.11.0",
4
+ "version": "18.11.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.11.0",
45
- "@oh-my-pi/pi-agent-core": "18.11.0",
46
- "@oh-my-pi/pi-ai": "18.11.0",
47
- "@oh-my-pi/pi-catalog": "18.11.0",
48
- "@oh-my-pi/pi-natives": "18.11.0",
49
- "@oh-my-pi/pi-utils": "18.11.0",
50
- "@oh-my-pi/pi-wire": "18.11.0",
51
- "@oh-my-pi/snapcompact": "18.11.0"
44
+ "@oh-my-pi/omptype": "18.11.2",
45
+ "@oh-my-pi/pi-agent-core": "18.11.2",
46
+ "@oh-my-pi/pi-ai": "18.11.2",
47
+ "@oh-my-pi/pi-catalog": "18.11.2",
48
+ "@oh-my-pi/pi-natives": "18.11.2",
49
+ "@oh-my-pi/pi-utils": "18.11.2",
50
+ "@oh-my-pi/pi-wire": "18.11.2",
51
+ "@oh-my-pi/snapcompact": "18.11.2"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -7,7 +7,8 @@
7
7
  * scrollbar, right sidebar (file management + commit form while dirty, HEAD
8
8
  * commit details with author avatar when clean). Submitting an empty commit
9
9
  * form generates an llm-git-compatible message; submitting populated fields
10
- * commits them. A footer shows key hints.
10
+ * commits them. A footer shows key hints; the staging and discarding ones are
11
+ * omitted while the tree is clean, where they cannot fire.
11
12
  *
12
13
  * `tab` moves focus between the diff and the sidebar; both panes take
13
14
  * arrows/PgUp/PgDn, vim motions (`j`/`k`/`h`/`l`/`g`/`G`), and mouse
@@ -22,7 +23,8 @@
22
23
  * refreshes. In the sidebar tree `←`/`→` collapse/expand directories,
23
24
  * `enter` opens the selected file in the diff pane, `space` stages or
24
25
  * unstages the selected row, and `delete` discards it — on a directory,
25
- * every file underneath it.
26
+ * every file underneath it. All of the staging and discarding keys are inert
27
+ * on a clean tree, which shows the HEAD commit instead of a change list.
26
28
  */
27
29
 
28
30
  import type { TspSpan } from "@oh-my-pi/pi-wire";
@@ -89,6 +91,28 @@ const STATUS_TTL_MS = 6_000;
89
91
 
90
92
  type Focus = "diff" | "sidebar";
91
93
 
94
+ /**
95
+ * Footer key hints. A clean tree renders the HEAD commit view, where stage
96
+ * and discard cannot fire: `#patchTargetFor` returns null and
97
+ * `#discardCurrentFile` returns early for `area: "commit"` rows, and the
98
+ * sidebar only builds stage/discard actions for the unstaged/staged
99
+ * sections. `commit` needs the commit form (absent on a clean tree) and
100
+ * `select` feeds the line-level stage/discard actions (no-ops on commit
101
+ * rows), so the commit view drops those too and says what it is showing.
102
+ */
103
+ export function gitFooterHints(clean: boolean, focus: Focus): string {
104
+ const edit = clean
105
+ ? ""
106
+ : focus === "diff"
107
+ ? ` · ${formatKeyHints(["s", "u"])} stage · ${formatKeyHints(["x", "delete"])} discard`
108
+ : ` · ${formatKeyHint("space")} stage · ${formatKeyHint("delete")} discard`;
109
+ const commit = clean ? "" : ` · ${formatKeyHint("c")} commit`;
110
+ const select = clean || focus !== "diff" ? "" : ` · ${formatKeyHints(["shift+up", "shift+down"])} select`;
111
+ return focus === "diff"
112
+ ? `${formatKeyHints(["alt+down", "alt+up"])} hunk · ${formatKeyHints(["]", "["])} file${select}${edit} · ${formatKeyHint("v")} view${commit} · ${formatKeyHint("?")} keys · ${formatKeyHint("q")} quit`
113
+ : `${formatKeyHints(["up", "down"])} move · ${formatKeyHints(["left", "right"])} fold${edit} · ${formatKeyHint("enter")} open · ${formatKeyHints(["alt+down", "alt+up"])} hunk${commit} · ${formatKeyHint("t")} tree · ${formatKeyHint("?")} keys · ${formatKeyHint("q")} quit`;
114
+ }
115
+
92
116
  /** Tone of a status message: its colour in the header and its span token natively. */
93
117
  type StatusTone = "success" | "warning" | "error" | "accent" | "dim";
94
118
 
@@ -793,14 +817,7 @@ class GitTuiComponent implements Component {
793
817
 
794
818
  // The empty middle carries the key hints (or a fresh status message).
795
819
  const status = this.#visibleStatus() ? theme.fg(this.#statusTone, this.#status) : "";
796
- const middle =
797
- status ||
798
- theme.fg(
799
- "dim",
800
- this.#focus === "diff"
801
- ? `${formatKeyHints(["alt+down", "alt+up"])} hunk · ${formatKeyHints(["]", "["])} file · ${formatKeyHints(["shift+up", "shift+down"])} select · ${formatKeyHints(["s", "u"])} stage · ${formatKeyHints(["x", "delete"])} discard · ${formatKeyHint("v")} view · ${formatKeyHint("c")} commit · ${formatKeyHint("?")} keys · ${formatKeyHint("q")} quit`
802
- : `${formatKeyHints(["up", "down"])} move · ${formatKeyHints(["left", "right"])} fold · ${formatKeyHint("space")} stage · ${formatKeyHint("delete")} discard · ${formatKeyHint("enter")} open · ${formatKeyHints(["alt+down", "alt+up"])} hunk · ${formatKeyHint("c")} commit · ${formatKeyHint("t")} tree · ${formatKeyHint("?")} keys · ${formatKeyHint("q")} quit`,
803
- );
820
+ const middle = status || theme.fg("dim", gitFooterHints(this.#model.clean, this.#focus));
804
821
  const free = width - row.width - right.width - 1;
805
822
  const middleText = free > visibleWidth(middle) + 4 ? middle : truncateToWidth(middle, Math.max(0, free - 4));
806
823
  const leftPad = Math.max(1, Math.floor((free - visibleWidth(middleText)) / 2));
@@ -1667,6 +1667,9 @@ export class Sidebar {
1667
1667
  text: ` ${theme.fg("dim", "parent:")} ${theme.fg("accent", head.parents.map(sha => sha.slice(0, 8)).join(" "))}`,
1668
1668
  });
1669
1669
  }
1670
+ // This view only appears when there is nothing to stage or discard, so say so
1671
+ // rather than letting the file list read as pending work.
1672
+ rows.push({ text: theme.fg("success", ` Working tree clean — showing committed ${head.shortSha}`) });
1670
1673
  rows.push({ text: theme.fg("borderMuted", "─".repeat(Math.max(0, width))) });
1671
1674
  if (!head.filesLoaded) {
1672
1675
  rows.push({ text: theme.fg("dim", " Loading changed files…") });
@@ -1675,8 +1678,9 @@ export class Sidebar {
1675
1678
 
1676
1679
  const additions = head.files.reduce((sum, file) => sum + (file.additions ?? 0), 0);
1677
1680
  const deletions = head.files.reduce((sum, file) => sum + (file.deletions ?? 0), 0);
1681
+ const count = `${head.files.length} file${head.files.length === 1 ? "" : "s"}`;
1678
1682
  rows.push({
1679
- text: ` ${theme.bold(`${head.files.length} modified`)} ${theme.fg("success", `+${additions}`)} ${theme.fg("error", `−${deletions}`)} ${theme.fg("dim", `· ${head.shortSha}`)}`,
1683
+ text: ` ${theme.bold(count)} ${theme.fg("dim", `in ${head.shortSha}`)} ${theme.fg("success", `+${additions}`)} ${theme.fg("error", `−${deletions}`)}`,
1680
1684
  });
1681
1685
  rows.push(this.#viewToggleRow(width));
1682
1686
  rows.push(...this.#entryRows(head.files, "commit"));
@@ -14,6 +14,11 @@ const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
14
14
  */
15
15
  const AT_PARTIAL_DELAY_MS = 150;
16
16
 
17
+ /**
18
+ * Without a deadline the native walk follows symlinks at unbounded depth and never returns.
19
+ */
20
+ const AT_FUZZY_TIMEOUT_MS = 1_500;
21
+
17
22
  function buildAutocompleteFuzzyDiscoveryProfile(
18
23
  query: string,
19
24
  basePath: string,
@@ -25,6 +30,7 @@ function buildAutocompleteFuzzyDiscoveryProfile(
25
30
  hidden: boolean;
26
31
  gitignore: boolean;
27
32
  cache: boolean;
33
+ timeoutMs: number;
28
34
  signal?: AbortSignal;
29
35
  } {
30
36
  return {
@@ -34,6 +40,7 @@ function buildAutocompleteFuzzyDiscoveryProfile(
34
40
  hidden: true,
35
41
  gitignore: true,
36
42
  cache: true,
43
+ timeoutMs: AT_FUZZY_TIMEOUT_MS,
37
44
  ...(signal ? { signal } : {}),
38
45
  };
39
46
  }
@@ -106,6 +113,18 @@ function extractQuotedPrefix(text: string): string | null {
106
113
  return text.slice(quoteStart);
107
114
  }
108
115
 
116
+ /** Extract the live whitespace-delimited or double-quoted `@` file token ending at the cursor. */
117
+ export function extractAtFilePrefix(text: string): string | null {
118
+ const quotedPrefix = extractQuotedPrefix(text);
119
+ if (quotedPrefix?.startsWith('@"')) {
120
+ return quotedPrefix;
121
+ }
122
+
123
+ const lastDelimiterIndex = findLastDelimiter(text);
124
+ const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
125
+ return text[tokenStart] === "@" ? text.slice(tokenStart) : null;
126
+ }
127
+
109
128
  function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {
110
129
  if (prefix.startsWith('@"')) {
111
130
  return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
@@ -719,7 +738,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
719
738
  }
720
739
 
721
740
  // Check for @ file reference (fuzzy search) - must be after a delimiter or at start
722
- const atPrefix = this.#extractAtPrefix(textBeforeCursor);
741
+ const atPrefix = extractAtFilePrefix(textBeforeCursor);
723
742
  if (atPrefix) {
724
743
  const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
725
744
  // Recursive fuzzy walks rooted outside the project (e.g. `@../`,
@@ -862,7 +881,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
862
881
 
863
882
  // Check if we're completing a file attachment (prefix starts with "@")
864
883
  if (prefix.startsWith("@")) {
865
- const liveAtPrefix = this.#extractAtPrefix(textBeforeCursor);
884
+ const liveAtPrefix = extractAtFilePrefix(textBeforeCursor);
866
885
  if (liveAtPrefix) {
867
886
  beforePrefix = currentLine.slice(0, cursorCol - liveAtPrefix.length);
868
887
  }
@@ -896,23 +915,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
896
915
  };
897
916
  }
898
917
 
899
- // Extract @ prefix for fuzzy file suggestions
900
- #extractAtPrefix(text: string): string | null {
901
- const quotedPrefix = extractQuotedPrefix(text);
902
- if (quotedPrefix?.startsWith('@"')) {
903
- return quotedPrefix;
904
- }
905
-
906
- const lastDelimiterIndex = findLastDelimiter(text);
907
- const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
908
-
909
- if (text[tokenStart] === "@") {
910
- return text.slice(tokenStart);
911
- }
912
-
913
- return null;
914
- }
915
-
916
918
  // Extract a path-like prefix from the text before cursor
917
919
  #extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
918
920
  const quotedPrefix = extractQuotedPrefix(text);
@@ -68,6 +68,7 @@ const DEFAULT_BYTE_LIMIT = 64 * 1024 * 1024;
68
68
  */
69
69
  export class BracketedPasteHandler {
70
70
  #buffer = "";
71
+ #bufferBytes = 0;
71
72
  #active = false;
72
73
  readonly #byteLimit: number;
73
74
 
@@ -88,7 +89,10 @@ export class BracketedPasteHandler {
88
89
  const start = data.indexOf(PASTE_START);
89
90
  const end = data.indexOf(PASTE_END);
90
91
  if (start !== -1 && (end === -1 || start < end)) {
91
- if (!this.#active) this.#buffer = "";
92
+ if (!this.#active) {
93
+ this.#buffer = "";
94
+ this.#bufferBytes = 0;
95
+ }
92
96
  this.#active = true;
93
97
  // Only strip nested starts before this paste's first end. Later starts
94
98
  // belong to the next paste in `remaining`.
@@ -100,6 +104,7 @@ export class BracketedPasteHandler {
100
104
 
101
105
  if (!this.#active) return { handled: false };
102
106
 
107
+ this.#bufferBytes += Buffer.byteLength(data, "utf8");
103
108
  this.#buffer += data;
104
109
 
105
110
  const endIndex = this.#buffer.indexOf(PASTE_END);
@@ -108,6 +113,7 @@ export class BracketedPasteHandler {
108
113
  const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
109
114
 
110
115
  this.#buffer = "";
116
+ this.#bufferBytes = 0;
111
117
  this.#active = false;
112
118
 
113
119
  return { handled: true, pasteContent, remaining };
@@ -118,9 +124,10 @@ export class BracketedPasteHandler {
118
124
  // neither lost nor held forever, and reset paste mode so subsequent
119
125
  // input recovers. See `StdinBuffer#abortPaste` for the sibling recovery
120
126
  // semantics inside `StdinBuffer`.
121
- if (this.#buffer.length > this.#byteLimit) {
127
+ if (this.#bufferBytes > this.#byteLimit) {
122
128
  const pasteContent = this.#buffer;
123
129
  this.#buffer = "";
130
+ this.#bufferBytes = 0;
124
131
  this.#active = false;
125
132
  return { handled: true, pasteContent, remaining: "" };
126
133
  }