@oh-my-pi/pi-tui 18.6.1 → 18.7.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +50 -58
  2. package/README.md +6 -2
  3. package/dist/types/app-keybindings.d.ts +7 -2
  4. package/dist/types/autocomplete.d.ts +5 -2
  5. package/dist/types/charts/chart-plan.d.ts +92 -0
  6. package/dist/types/charts/chart-svg.d.ts +30 -0
  7. package/dist/types/charts/table-data.d.ts +107 -0
  8. package/dist/types/chat/assistant-message.d.ts +10 -1
  9. package/dist/types/chat/chat-transcript-builder.d.ts +3 -0
  10. package/dist/types/chat/figure-markdown.d.ts +42 -0
  11. package/dist/types/chat/svg-figure.d.ts +33 -0
  12. package/dist/types/chat/svg-source.d.ts +49 -0
  13. package/dist/types/chat/table-chart.d.ts +88 -0
  14. package/dist/types/chat/user-message.d.ts +2 -2
  15. package/dist/types/chrome/status-notice.d.ts +15 -3
  16. package/dist/types/components/editor.d.ts +5 -1
  17. package/dist/types/components/image.d.ts +23 -0
  18. package/dist/types/components/input.d.ts +1 -0
  19. package/dist/types/components/loader.d.ts +5 -6
  20. package/dist/types/components/markdown.d.ts +9 -0
  21. package/dist/types/fuzzy.d.ts +14 -0
  22. package/dist/types/key-hint-format.d.ts +8 -0
  23. package/dist/types/keybindings.d.ts +1 -1
  24. package/dist/types/native/backend.d.ts +2 -2
  25. package/dist/types/native/node.d.ts +6 -0
  26. package/dist/types/native/overlay.d.ts +3 -3
  27. package/dist/types/overlays/agent-hub.d.ts +1 -0
  28. package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -0
  29. package/dist/types/overlays/agents-hub.d.ts +9 -0
  30. package/dist/types/overlays/annotation-overlay.d.ts +5 -2
  31. package/dist/types/overlays/btw-history-panel.d.ts +5 -1
  32. package/dist/types/overlays/copy-selector.d.ts +1 -0
  33. package/dist/types/overlays/model-browser.d.ts +25 -3
  34. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  35. package/dist/types/overlays/rewind-selector.d.ts +1 -0
  36. package/dist/types/overlays/usage-dashboard.d.ts +4 -4
  37. package/dist/types/prompt/custom-editor.d.ts +10 -4
  38. package/dist/types/prompt/interactive-context-helpers.d.ts +3 -0
  39. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  40. package/dist/types/render/composite.d.ts +2 -0
  41. package/dist/types/space-hold.d.ts +40 -25
  42. package/dist/types/terminal-capabilities.d.ts +13 -0
  43. package/dist/types/tools/glob.d.ts +4 -0
  44. package/dist/types/tui.d.ts +5 -0
  45. package/dist/types/utils.d.ts +6 -0
  46. package/package.json +9 -9
  47. package/src/app-keybindings.ts +6 -1
  48. package/src/apps/git/diff-pane.ts +42 -16
  49. package/src/apps/git/git-tui.ts +9 -9
  50. package/src/apps/git/sidebar.ts +6 -1
  51. package/src/autocomplete.ts +8 -10
  52. package/src/charts/chart-plan.ts +367 -0
  53. package/src/charts/chart-svg.ts +693 -0
  54. package/src/charts/table-data.ts +436 -0
  55. package/src/chat/assistant-message.ts +165 -25
  56. package/src/chat/chat-transcript-builder.ts +8 -0
  57. package/src/chat/figure-markdown.ts +174 -0
  58. package/src/chat/svg-figure.ts +364 -0
  59. package/src/chat/svg-source.ts +188 -0
  60. package/src/chat/table-chart.ts +301 -0
  61. package/src/chat/tool-execution.ts +6 -1
  62. package/src/chat/user-message.ts +16 -5
  63. package/src/chrome/status-notice.ts +25 -7
  64. package/src/chrome/transcript-container.ts +13 -4
  65. package/src/components/editor.ts +66 -30
  66. package/src/components/image.ts +95 -2
  67. package/src/components/input.ts +14 -6
  68. package/src/components/loader.ts +10 -17
  69. package/src/components/markdown.ts +6 -3
  70. package/src/fuzzy.ts +59 -19
  71. package/src/hotkeys-markdown.ts +9 -2
  72. package/src/key-hint-format.ts +25 -10
  73. package/src/keybindings.ts +1 -1
  74. package/src/native/backend.ts +10 -3
  75. package/src/native/node.ts +6 -0
  76. package/src/native/overlay.ts +4 -4
  77. package/src/overlays/agent-hub.ts +4 -0
  78. package/src/overlays/agent-transcript-viewer.ts +4 -0
  79. package/src/overlays/agents-hub.ts +14 -1
  80. package/src/overlays/annotation-overlay.ts +179 -80
  81. package/src/overlays/ask-dialog.ts +1 -1
  82. package/src/overlays/btw-history-panel.ts +9 -2
  83. package/src/overlays/copy-selector.ts +2 -0
  84. package/src/overlays/extensions/extension-dashboard.ts +4 -4
  85. package/src/overlays/model-browser.ts +169 -76
  86. package/src/overlays/pause-screen.ts +2 -2
  87. package/src/overlays/plan-review-overlay.ts +41 -4
  88. package/src/overlays/rewind-selector.ts +2 -0
  89. package/src/overlays/usage-dashboard.ts +17 -8
  90. package/src/overlays/usage-row.ts +22 -8
  91. package/src/prompt/composer.ts +2 -14
  92. package/src/prompt/custom-editor.ts +188 -67
  93. package/src/prompt/interactive-context-helpers.ts +5 -0
  94. package/src/prompt/model-mention-autocomplete.ts +14 -14
  95. package/src/prompt/queued-messages.ts +6 -4
  96. package/src/render/composite.ts +62 -0
  97. package/src/space-hold.ts +156 -52
  98. package/src/status-line/segments.ts +3 -1
  99. package/src/terminal-capabilities.ts +42 -23
  100. package/src/tools/glob.ts +23 -6
  101. package/src/tui.ts +154 -119
  102. package/src/utils.ts +19 -0
@@ -1,45 +1,60 @@
1
- /** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
2
- * deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
1
+ import { type KeyId } from "./keys.js";
2
+ /** Printable text a push-to-talk candidate key types (plain or shifted single characters and Space),
3
+ * or undefined for non-printable keys and chords, which the gesture reserves instead of typing. */
4
+ export declare function getSpaceHoldText(data: string, canonical: string | undefined): string | undefined;
5
+ /** Max gap (ms) between two configured-key presses for a later one to count as OS key auto-repeat
6
+ * rather than a deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
3
7
  export declare const SPACE_REPEAT_MAX_GAP_MS = 120;
4
- /** Two consecutive inter-space gaps are "mechanical" (machine-driven auto-repeat) when both are
8
+ /** Two consecutive inter-key gaps are "mechanical" (machine-driven auto-repeat) when both are
5
9
  * within {@link SPACE_REPEAT_MAX_GAP_MS} and differ by no more than this — an absolute jitter floor
6
10
  * or, for slower repeat rates, {@link SPACE_REPEAT_JITTER_RATIO} of the smaller gap. OS key-repeat
7
- * is metronomic; a human smashing the bar is fast but irregular, so its deltas never stay this
8
- * steady. */
11
+ * is metronomic; human typing is irregular, so its deltas do not stay this steady. */
9
12
  export declare const SPACE_REPEAT_JITTER_MS = 18;
10
13
  export declare const SPACE_REPEAT_JITTER_RATIO = 0.35;
11
- /** Consecutive mechanical (fast + steady) deltas that confirm the space bar is held and start
12
- * recording. Needs a sustained metronomic cadence, so jittery smashing and deliberate taps never
14
+ /** Consecutive mechanical (fast + steady) deltas that confirm a configured key is held and start
15
+ * recording. Needs a sustained metronomic cadence, so jittery typing and deliberate taps never
13
16
  * reach it. */
14
17
  export declare const SPACE_HOLD_MECHANICAL_RUN = 2;
15
- /** Idle gap (ms) after the last repeated space that counts as the space bar being released, ending
16
- * the push-to-talk recording. Must comfortably exceed the OS key-repeat interval. */
18
+ /** Idle gap (ms) after the last repeated configured-key press that counts as release and ends
19
+ * push-to-talk. Must comfortably exceed the OS key-repeat interval. */
17
20
  export declare const SPACE_HOLD_RELEASE_MS = 250;
18
- /** What a recognized space-bar hold drives — the push-to-talk start and stop. */
21
+ /** A release timer firing more than this (ms) past its deadline means the event loop stalled (a
22
+ * synchronous microphone open, GC, a heavy render) while the bar may still be held: the repeats
23
+ * emitted during the stall are queued but unread, so the stall must not read as a release. */
24
+ export declare const SPACE_HOLD_STALL_SLACK_MS = 100;
25
+ /** What a recognized push-to-talk hold drives — the start and stop callbacks. */
19
26
  export interface SpaceHoldHandler {
20
- /** Gate for the gesture. Returns false to keep the space bar inserting spaces normally, so
21
- * disabling push-to-talk restores plain space behavior. */
27
+ /** Gate for the gesture. Returns false to let matching keys behave normally. */
22
28
  enabled(): boolean;
23
- /** A sustained hold was recognized. The optimistically-typed spaces have already been deleted. */
29
+ /** A sustained hold was recognized; any safely typed candidate text has already been deleted. */
24
30
  onStart(): void;
25
- /** The held space bar was released (an idle gap with no further repeated spaces, or any other key). */
31
+ /** The held key was released (an idle gap with no further repeats, or any other key). */
26
32
  onEnd(): void;
27
33
  }
28
34
  /** What the host must do with a keypress after {@link SpaceHoldGesture.process} has seen it:
29
- * `"pass"` — handle it normally; `"type"` — insert the space now (it is tracked back out if the run
30
- * turns into a hold); `"swallow"` — drop it, the gesture consumed it. */
35
+ * `"pass"` — handle it normally; `"type"` — insert its safely decoded printable text now (it is
36
+ * tracked back out if the run turns into a hold); `"swallow"` — drop it, the gesture consumed it. */
31
37
  export type SpaceHoldStep = "pass" | "type" | "swallow";
32
- /** Space-hold push-to-talk state machine. A held space bar emits OS auto-repeat: a *steady* stream
33
- * of spaces at a fixed fast interval. We watch the inter-space deltas and only recognize a hold once
34
- * {@link SPACE_HOLD_MECHANICAL_RUN} consecutive deltas are "mechanical" — both auto-repeat-fast and
35
- * near-identical (see {@link gapsAreMechanical}). Smashing the bar is fast but jittery and deliberate
36
- * taps are too slow, so neither escalates and both keep typing real spaces; the few spaces typed
37
- * before a real hold is recognized are tracked back out of the host input. */
38
+ /** Push-to-talk key-hold state machine. A held key emits OS auto-repeat at a steady fast interval.
39
+ * We watch inter-key deltas and only recognize a hold once {@link SPACE_HOLD_MECHANICAL_RUN}
40
+ * consecutive deltas are mechanical (see {@link gapsAreMechanical}). Deliberate taps and jittery
41
+ * runs stay typed; only text typed during the current steady run is removed on recognition. */
38
42
  export declare class SpaceHoldGesture {
39
43
  #private;
40
- /** Unset (the default) keeps the space bar typing spaces. */
44
+ /** Configured alternative keys. Alternatives are tracked independently and never combine into one run. */
45
+ get keys(): readonly KeyId[];
46
+ set keys(keys: readonly KeyId[]);
47
+ /** Unset (the default) keeps configured keys behaving normally. */
41
48
  handler: SpaceHoldHandler | undefined;
42
49
  constructor(deleteTyped: (count: number) => void, allowed?: () => boolean);
43
- /** Feed one keypress, `isSpace` when it is a plain space bar press. */
44
- process(isSpace: boolean): SpaceHoldStep;
50
+ /** Pure focused-owner preflight on raw input: whether the key must reach the host before
51
+ * TUI-wide input listeners. Only non-printable bindings are claimed — they would otherwise
52
+ * trigger app shortcuts mid-hold; printable candidates keep the normal listener path so
53
+ * raw-input observers (e.g. extensions) still see typed text, as they did before remapping. */
54
+ shouldRoute(data: string): boolean;
55
+ /** Feed one canonical keypress and, when safely printable, its decoded text length. The host
56
+ * records the actual inserted length with {@link recordTyped} after applying normal typing. */
57
+ process(key: string | undefined, insertedCharacterLength?: number): SpaceHoldStep;
58
+ /** Account for text the host actually inserted for the preceding `"type"` step. */
59
+ recordTyped(insertedCharacterLength: number): void;
45
60
  }
@@ -284,6 +284,13 @@ export interface ImageRenderOptions {
284
284
  placementId?: number;
285
285
  /** When true (Kitty + {@link imageId}), also return the one-time transmit sequence. */
286
286
  includeTransmit?: boolean;
287
+ /**
288
+ * SIXEL sequence for the target pixel size renderImage computes, so the
289
+ * caller can encode off the JS thread: answer `undefined` while the encode
290
+ * is pending (the image's rows stay reserved) and `null` once it failed.
291
+ * Without a provider, renderImage encodes synchronously.
292
+ */
293
+ sixel?: (widthPx: number, heightPx: number) => string | null | undefined;
287
294
  }
288
295
  export declare function getCellDimensions(): CellDimensions;
289
296
  export declare function setCellDimensions(dims: CellDimensions): void;
@@ -381,6 +388,12 @@ export declare function getJpegDimensions(base64Data: string): ImageDimensions |
381
388
  export declare function getGifDimensions(base64Data: string): ImageDimensions | null;
382
389
  export declare function getWebpDimensions(base64Data: string): ImageDimensions | null;
383
390
  export declare function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null;
391
+ /**
392
+ * SIXEL sequence for `base64Data` at the given pixel size, encoded on the JS
393
+ * thread; `null` when the encode fails. For output that cannot wait for an
394
+ * off-thread encode.
395
+ */
396
+ export declare function encodeSixelNow(base64Data: string, widthPx: number, heightPx: number): string | null;
384
397
  export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): {
385
398
  sequence?: string;
386
399
  lines?: string[];
@@ -12,6 +12,10 @@ export interface GlobToolDetails {
12
12
  fileCount?: number;
13
13
  files?: string[];
14
14
  truncated?: boolean;
15
+ /** The scan hit the tool deadline, so the listed files are an incomplete
16
+ * set rather than a complete answer. Distinct from `truncated`, which also
17
+ * covers result-limit and output truncation. */
18
+ timedOut?: boolean;
15
19
  error?: string;
16
20
  /** Working directory at search time. Used by the renderer to resolve relative
17
21
  * file paths to absolute paths for OSC 8 hyperlinks. */
@@ -150,6 +150,11 @@ export interface Component {
150
150
  describeScreen?(cx: DescribeContext): NativeScreen;
151
151
  /** User actions on nodes this component described (toggle, select, activate, custom). */
152
152
  handleNativeEvent?(event: NativeUiEvent): void;
153
+ /**
154
+ * Pure preflight for focused input that must precede TUI-wide input listeners
155
+ * and debug shortcuts. A true result routes the event to this component first.
156
+ */
157
+ capturesInput?(data: string): boolean;
153
158
  /**
154
159
  * Optional handler for keyboard input when component has focus
155
160
  */
@@ -26,6 +26,12 @@ export interface TextSizingOptions {
26
26
  */
27
27
  export declare function encodeTextSized(text: string, options?: TextSizingOptions): string;
28
28
  export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult;
29
+ /**
30
+ * Hard-wrap one literal terminal row without trimming spaces or splitting graphemes.
31
+ * An oversized grapheme is emitted intact even when it exceeds the requested width.
32
+ * Zero-cell rows remain one empty row, matching restored-terminal reflow.
33
+ */
34
+ export declare function wrapLiteralLine(line: string, width: number): string[];
29
35
  export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null | "", pad?: boolean | null): string;
30
36
  export declare function wrapTextWithAnsi(text: string, width: number): string[];
31
37
  export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean): ExtractSegmentsResult;
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.6.1",
4
+ "version": "18.7.0",
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.6.1",
45
- "@oh-my-pi/pi-agent-core": "18.6.1",
46
- "@oh-my-pi/pi-ai": "18.6.1",
47
- "@oh-my-pi/pi-catalog": "18.6.1",
48
- "@oh-my-pi/pi-natives": "18.6.1",
49
- "@oh-my-pi/pi-utils": "18.6.1",
50
- "@oh-my-pi/pi-wire": "18.6.1",
51
- "@oh-my-pi/snapcompact": "18.6.1"
44
+ "@oh-my-pi/omptype": "18.7.0",
45
+ "@oh-my-pi/pi-agent-core": "18.7.0",
46
+ "@oh-my-pi/pi-ai": "18.7.0",
47
+ "@oh-my-pi/pi-catalog": "18.7.0",
48
+ "@oh-my-pi/pi-natives": "18.7.0",
49
+ "@oh-my-pi/pi-utils": "18.7.0",
50
+ "@oh-my-pi/pi-wire": "18.7.0",
51
+ "@oh-my-pi/snapcompact": "18.7.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -60,6 +60,7 @@ interface AppKeybindings {
60
60
  "app.tree.unfoldOrDown": true;
61
61
  "app.plan.toggle": true;
62
62
  "app.history.search": true;
63
+ "app.stt.pushToTalk": true;
63
64
  "app.stt.toggle": true;
64
65
  "app.live.toggle": true;
65
66
  }
@@ -237,9 +238,13 @@ export const KEYBINDINGS = {
237
238
  defaultKeys: "ctrl+r",
238
239
  description: "Search history",
239
240
  },
241
+ "app.stt.pushToTalk": {
242
+ defaultKeys: "space",
243
+ description: "Hold to record speech-to-text",
244
+ },
240
245
  "app.stt.toggle": {
241
246
  defaultKeys: [],
242
- description: "Toggle speech-to-text (default gesture: hold Space)",
247
+ description: "Toggle speech-to-text recording",
243
248
  },
244
249
  "app.live.toggle": {
245
250
  defaultKeys: "ctrl+l",
@@ -601,6 +601,12 @@ export class DiffPane {
601
601
  #layoutCache: { key: string; visuals: Visual[] } | undefined;
602
602
  /** Per visible row: clickable hunk-button ranges recorded during render. */
603
603
  #hits: ({ hunk: number; primary?: [number, number]; discard?: [number, number] } | undefined)[] = [];
604
+ /**
605
+ * Asset images from the last render, by placement and size. Reusing them
606
+ * lets a SIXEL image show the sequence its off-thread encode delivers on a
607
+ * later render; a fresh `Image` per render would restart the encode forever.
608
+ */
609
+ #assetImages = new Map<string, Image>();
604
610
  constructor(imageBudget?: ImageBudget) {
605
611
  this.#imageBudget = imageBudget;
606
612
  }
@@ -1103,8 +1109,16 @@ export class DiffPane {
1103
1109
  const leftWidth = Math.max(1, Math.floor((width - 1) / 2));
1104
1110
  const rightWidth = Math.max(1, width - leftWidth - 1);
1105
1111
  const bodyHeight = Math.max(0, height - 1);
1106
- const oldLines = this.#renderAssetSide(asset.old, leftWidth, bodyHeight, `old:${asset.filePath}`);
1107
- const newLines = this.#renderAssetSide(asset.new, rightWidth, bodyHeight, `new:${asset.filePath}`);
1112
+ const previousImages = this.#assetImages;
1113
+ this.#assetImages = new Map();
1114
+ const oldLines = this.#renderAssetSide(asset.old, leftWidth, bodyHeight, `old:${asset.filePath}`, previousImages);
1115
+ const newLines = this.#renderAssetSide(
1116
+ asset.new,
1117
+ rightWidth,
1118
+ bodyHeight,
1119
+ `new:${asset.filePath}`,
1120
+ previousImages,
1121
+ );
1108
1122
  const border = theme.fg("borderMuted", "│");
1109
1123
  const lines: string[] = [];
1110
1124
  for (let index = 0; index < height; index++) {
@@ -1125,24 +1139,36 @@ export class DiffPane {
1125
1139
  return lines;
1126
1140
  }
1127
1141
 
1128
- #renderAssetSide(side: FileAssetSide, width: number, height: number, placementKey: string): readonly string[] {
1142
+ #renderAssetSide(
1143
+ side: FileAssetSide,
1144
+ width: number,
1145
+ height: number,
1146
+ placementKey: string,
1147
+ previousImages: ReadonlyMap<string, Image>,
1148
+ ): readonly string[] {
1129
1149
  if (height <= 0) return [];
1130
1150
  let content: readonly string[];
1131
1151
  if (side.kind === "image") {
1132
1152
  const image = side.image;
1133
- content = new Image(
1134
- image.data,
1135
- image.mimeType,
1136
- { fallbackColor: text => theme.fg("dim", text) },
1137
- {
1138
- maxWidthCells: Math.max(1, width - 2),
1139
- maxHeightCells: height,
1140
- filename: this.#asset?.filePath,
1141
- budget: this.#imageBudget,
1142
- imageKey: `git-review:${placementKey}:${image.key}`,
1143
- },
1144
- { widthPx: image.widthPx, heightPx: image.heightPx },
1145
- ).render(width);
1153
+ const imageKey = `git-review:${placementKey}:${image.key}`;
1154
+ const cacheKey = `${imageKey}:${width}x${height}`;
1155
+ const component =
1156
+ previousImages.get(cacheKey) ??
1157
+ new Image(
1158
+ image.data,
1159
+ image.mimeType,
1160
+ { fallbackColor: text => theme.fg("dim", text) },
1161
+ {
1162
+ maxWidthCells: Math.max(1, width - 2),
1163
+ maxHeightCells: height,
1164
+ filename: this.#asset?.filePath,
1165
+ budget: this.#imageBudget,
1166
+ imageKey,
1167
+ },
1168
+ { widthPx: image.widthPx, heightPx: image.heightPx },
1169
+ );
1170
+ this.#assetImages.set(cacheKey, component);
1171
+ content = component.render(width);
1146
1172
  } else {
1147
1173
  let details: string[];
1148
1174
  switch (side.kind) {
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import type { TspSpan } from "@oh-my-pi/pi-wire";
29
- import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
29
+ import { formatKeyHint, formatKeyHints, formatTooltipKey } from "../../app-keybindings";
30
30
  import { SplitPane } from "../../components/layout/split-pane";
31
31
  import { Stack } from "../../components/layout/stack";
32
32
  import { matchesKey } from "../../keys";
@@ -1030,8 +1030,8 @@ class GitTuiComponent implements Component {
1030
1030
  const right: NativeNode[] = [
1031
1031
  row(
1032
1032
  [
1033
- iconButton("chev-up", "prev-hunk", `Previous change (${formatKeyHint("alt+up")})`),
1034
- iconButton("chev", "next-hunk", `Next change (${formatKeyHint("alt+down")})`),
1033
+ iconButton("chev-up", "prev-hunk", `Previous change (${formatTooltipKey("alt+up")})`),
1034
+ iconButton("chev", "next-hunk", `Next change (${formatTooltipKey("alt+down")})`),
1035
1035
  ],
1036
1036
  { gap: "none", align: "center" },
1037
1037
  ),
@@ -1052,13 +1052,13 @@ class GitTuiComponent implements Component {
1052
1052
  "type",
1053
1053
  "whitespace",
1054
1054
  this.#whitespace === "off"
1055
- ? `Ignore whitespace (${formatKeyHint("b")})`
1055
+ ? `Ignore whitespace (${formatTooltipKey("b")})`
1056
1056
  : this.#whitespace === "whitespace"
1057
- ? `Ignoring whitespace; also ignore formatting (${formatKeyHint("b")})`
1058
- : `Ignoring formatting and imports; show all (${formatKeyHint("b")})`,
1057
+ ? `Ignoring whitespace; also ignore formatting (${formatTooltipKey("b")})`
1058
+ : `Ignoring formatting and imports; show all (${formatTooltipKey("b")})`,
1059
1059
  this.#whitespace !== "off",
1060
1060
  ),
1061
- iconButton("corner-down-right", "wrap", `Wrap lines (${formatKeyHint("w")})`, this.#pane.wrap),
1061
+ iconButton("corner-down-right", "wrap", `Wrap lines (${formatTooltipKey("w")})`, this.#pane.wrap),
1062
1062
  );
1063
1063
  }
1064
1064
  if (file?.area === "unstaged" || file?.area === "staged") {
@@ -1070,8 +1070,8 @@ class GitTuiComponent implements Component {
1070
1070
  );
1071
1071
  }
1072
1072
  right.push(
1073
- iconButton("keyboard", "help", `Keyboard shortcuts (${formatKeyHint("?")})`),
1074
- iconButton("x", "close", `Close (${formatKeyHint("q")})`),
1073
+ iconButton("keyboard", "help", `Keyboard shortcuts (${formatTooltipKey("?")})`),
1074
+ iconButton("x", "close", `Close (${formatTooltipKey("q")})`),
1075
1075
  );
1076
1076
  return keyed(
1077
1077
  row(
@@ -1699,7 +1699,12 @@ export class Sidebar {
1699
1699
  png,
1700
1700
  "image/png",
1701
1701
  { fallbackColor: text => theme.fg("dim", text) },
1702
- { maxHeightCells: 3, budget: this.#imageBudget, imageKey: `git-avatar:${email}` },
1702
+ {
1703
+ maxHeightCells: 3,
1704
+ budget: this.#imageBudget,
1705
+ imageKey: `git-avatar:${email}`,
1706
+ requestRender: this.#requestRender,
1707
+ },
1703
1708
  ),
1704
1709
  };
1705
1710
  }
@@ -263,8 +263,11 @@ export interface AutocompleteProvider {
263
263
 
264
264
  /** Get inline hint text to show as dim ghost text after the cursor */
265
265
  getInlineHint?(lines: string[], cursorLine: number, cursorCol: number): string | null;
266
- /** Synchronously try to complete a slash command at the start of a line (no async I/O). */
267
- /** Returns matched items and the full prefix, or null if not applicable. */
266
+ /**
267
+ * Synchronously list slash command-name completions for a leading slash token (no async I/O).
268
+ * Mirrors the command-name branch of {@link getSuggestions}, including the bare `/` listing and
269
+ * the collapsed `/skill:` namespace row. Returns null outside a command-name token or with no match.
270
+ */
268
271
  trySyncSlashCompletion?(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null;
269
272
  /**
270
273
  * Synchronously try to expand text immediately before the cursor (no async I/O).
@@ -806,7 +809,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
806
809
  };
807
810
  }
808
811
 
809
- // Slash command suggestions can be accepted before the debounced refresh
812
+ // Slash command suggestions can be accepted before an async refresh
810
813
  // catches up to newly typed characters. Replace the live command token,
811
814
  // not only the prefix captured when the suggestion list was rendered.
812
815
  // Absolute-path completions share the leading-slash prefix shape but
@@ -1300,19 +1303,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1300
1303
  const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
1301
1304
  if (slashStart === null) return null;
1302
1305
  const commandText = textBeforeCursor.slice(slashStart);
1303
- if (commandText.length <= 1) return null; // Bare "/" alone, don't auto-complete
1304
1306
  if (commandText.includes(" ")) return null; // Only complete command name, not args
1305
1307
 
1306
- const prefix = commandText.slice(1);
1307
- const lowerPrefix = prefix.toLowerCase();
1308
-
1309
- // The `/skill:` namespace row is excluded here: the sync path submits
1310
- // immediately after applying, and the bare namespace is not a command.
1308
+ const lowerPrefix = commandText.slice(1).toLowerCase();
1311
1309
  const matches = buildSlashCommandCompletions(
1312
1310
  collapseSkillNamespace(this.#commands, lowerPrefix),
1313
1311
  lowerPrefix,
1314
1312
  this.#commandUsage,
1315
- ).filter(item => item.value !== SKILL_NAMESPACE);
1313
+ );
1316
1314
 
1317
1315
  if (matches.length === 0) return null;
1318
1316
  // Mirror `getSuggestions`: preserve leading whitespace so the editor's