indusagi-coding-agent 0.1.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 (240) hide show
  1. package/CHANGELOG.md +2249 -0
  2. package/README.md +546 -0
  3. package/dist/cli/args.js +282 -0
  4. package/dist/cli/config-selector.js +30 -0
  5. package/dist/cli/file-processor.js +78 -0
  6. package/dist/cli/list-models.js +91 -0
  7. package/dist/cli/session-picker.js +31 -0
  8. package/dist/cli.js +10 -0
  9. package/dist/config.js +158 -0
  10. package/dist/core/agent-session.js +2097 -0
  11. package/dist/core/auth-storage.js +278 -0
  12. package/dist/core/bash-executor.js +211 -0
  13. package/dist/core/compaction/branch-summarization.js +241 -0
  14. package/dist/core/compaction/compaction.js +606 -0
  15. package/dist/core/compaction/index.js +6 -0
  16. package/dist/core/compaction/utils.js +137 -0
  17. package/dist/core/diagnostics.js +1 -0
  18. package/dist/core/event-bus.js +24 -0
  19. package/dist/core/exec.js +70 -0
  20. package/dist/core/export-html/ansi-to-html.js +248 -0
  21. package/dist/core/export-html/index.js +221 -0
  22. package/dist/core/export-html/template.css +905 -0
  23. package/dist/core/export-html/template.html +54 -0
  24. package/dist/core/export-html/template.js +1549 -0
  25. package/dist/core/export-html/tool-renderer.js +56 -0
  26. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  27. package/dist/core/export-html/vendor/marked.min.js +6 -0
  28. package/dist/core/extensions/index.js +8 -0
  29. package/dist/core/extensions/loader.js +395 -0
  30. package/dist/core/extensions/runner.js +499 -0
  31. package/dist/core/extensions/types.js +31 -0
  32. package/dist/core/extensions/wrapper.js +101 -0
  33. package/dist/core/footer-data-provider.js +133 -0
  34. package/dist/core/index.js +8 -0
  35. package/dist/core/keybindings.js +140 -0
  36. package/dist/core/messages.js +122 -0
  37. package/dist/core/model-registry.js +454 -0
  38. package/dist/core/model-resolver.js +309 -0
  39. package/dist/core/package-manager.js +1142 -0
  40. package/dist/core/prompt-templates.js +250 -0
  41. package/dist/core/resource-loader.js +569 -0
  42. package/dist/core/sdk.js +225 -0
  43. package/dist/core/session-manager.js +1078 -0
  44. package/dist/core/settings-manager.js +430 -0
  45. package/dist/core/skills.js +339 -0
  46. package/dist/core/system-prompt.js +136 -0
  47. package/dist/core/timings.js +24 -0
  48. package/dist/core/tools/bash.js +226 -0
  49. package/dist/core/tools/edit-diff.js +242 -0
  50. package/dist/core/tools/edit.js +145 -0
  51. package/dist/core/tools/find.js +205 -0
  52. package/dist/core/tools/grep.js +238 -0
  53. package/dist/core/tools/index.js +60 -0
  54. package/dist/core/tools/ls.js +117 -0
  55. package/dist/core/tools/path-utils.js +52 -0
  56. package/dist/core/tools/read.js +165 -0
  57. package/dist/core/tools/truncate.js +204 -0
  58. package/dist/core/tools/write.js +77 -0
  59. package/dist/index.js +41 -0
  60. package/dist/main.js +565 -0
  61. package/dist/migrations.js +260 -0
  62. package/dist/modes/index.js +7 -0
  63. package/dist/modes/interactive/components/armin.js +328 -0
  64. package/dist/modes/interactive/components/assistant-message.js +86 -0
  65. package/dist/modes/interactive/components/bash-execution.js +155 -0
  66. package/dist/modes/interactive/components/bordered-loader.js +47 -0
  67. package/dist/modes/interactive/components/branch-summary-message.js +41 -0
  68. package/dist/modes/interactive/components/compaction-summary-message.js +42 -0
  69. package/dist/modes/interactive/components/config-selector.js +458 -0
  70. package/dist/modes/interactive/components/countdown-timer.js +27 -0
  71. package/dist/modes/interactive/components/custom-editor.js +61 -0
  72. package/dist/modes/interactive/components/custom-message.js +80 -0
  73. package/dist/modes/interactive/components/diff.js +132 -0
  74. package/dist/modes/interactive/components/dynamic-border.js +19 -0
  75. package/dist/modes/interactive/components/extension-editor.js +96 -0
  76. package/dist/modes/interactive/components/extension-input.js +54 -0
  77. package/dist/modes/interactive/components/extension-selector.js +70 -0
  78. package/dist/modes/interactive/components/footer.js +213 -0
  79. package/dist/modes/interactive/components/index.js +31 -0
  80. package/dist/modes/interactive/components/keybinding-hints.js +60 -0
  81. package/dist/modes/interactive/components/login-dialog.js +138 -0
  82. package/dist/modes/interactive/components/model-selector.js +253 -0
  83. package/dist/modes/interactive/components/oauth-selector.js +91 -0
  84. package/dist/modes/interactive/components/scoped-models-selector.js +262 -0
  85. package/dist/modes/interactive/components/session-selector-search.js +145 -0
  86. package/dist/modes/interactive/components/session-selector.js +698 -0
  87. package/dist/modes/interactive/components/settings-selector.js +250 -0
  88. package/dist/modes/interactive/components/show-images-selector.js +33 -0
  89. package/dist/modes/interactive/components/skill-invocation-message.js +44 -0
  90. package/dist/modes/interactive/components/theme-selector.js +43 -0
  91. package/dist/modes/interactive/components/thinking-selector.js +45 -0
  92. package/dist/modes/interactive/components/tool-execution.js +608 -0
  93. package/dist/modes/interactive/components/tree-selector.js +892 -0
  94. package/dist/modes/interactive/components/user-message-selector.js +109 -0
  95. package/dist/modes/interactive/components/user-message.js +15 -0
  96. package/dist/modes/interactive/components/visual-truncate.js +32 -0
  97. package/dist/modes/interactive/interactive-mode.js +3576 -0
  98. package/dist/modes/interactive/theme/dark.json +85 -0
  99. package/dist/modes/interactive/theme/light.json +84 -0
  100. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  101. package/dist/modes/interactive/theme/theme.js +938 -0
  102. package/dist/modes/print-mode.js +96 -0
  103. package/dist/modes/rpc/rpc-client.js +390 -0
  104. package/dist/modes/rpc/rpc-mode.js +448 -0
  105. package/dist/modes/rpc/rpc-types.js +7 -0
  106. package/dist/utils/changelog.js +86 -0
  107. package/dist/utils/clipboard-image.js +116 -0
  108. package/dist/utils/clipboard.js +58 -0
  109. package/dist/utils/frontmatter.js +25 -0
  110. package/dist/utils/git.js +5 -0
  111. package/dist/utils/image-convert.js +34 -0
  112. package/dist/utils/image-resize.js +180 -0
  113. package/dist/utils/mime.js +25 -0
  114. package/dist/utils/photon.js +120 -0
  115. package/dist/utils/shell.js +164 -0
  116. package/dist/utils/sleep.js +16 -0
  117. package/dist/utils/tools-manager.js +186 -0
  118. package/docs/compaction.md +390 -0
  119. package/docs/custom-provider.md +538 -0
  120. package/docs/development.md +69 -0
  121. package/docs/extensions.md +1733 -0
  122. package/docs/images/doom-extension.png +0 -0
  123. package/docs/images/interactive-mode.png +0 -0
  124. package/docs/images/tree-view.png +0 -0
  125. package/docs/json.md +79 -0
  126. package/docs/keybindings.md +162 -0
  127. package/docs/models.md +193 -0
  128. package/docs/packages.md +163 -0
  129. package/docs/prompt-templates.md +67 -0
  130. package/docs/providers.md +147 -0
  131. package/docs/rpc.md +1048 -0
  132. package/docs/sdk.md +957 -0
  133. package/docs/session.md +412 -0
  134. package/docs/settings.md +216 -0
  135. package/docs/shell-aliases.md +13 -0
  136. package/docs/skills.md +226 -0
  137. package/docs/terminal-setup.md +65 -0
  138. package/docs/themes.md +295 -0
  139. package/docs/tree.md +219 -0
  140. package/docs/tui.md +887 -0
  141. package/docs/windows.md +17 -0
  142. package/examples/README.md +25 -0
  143. package/examples/extensions/README.md +192 -0
  144. package/examples/extensions/antigravity-image-gen.ts +414 -0
  145. package/examples/extensions/auto-commit-on-exit.ts +49 -0
  146. package/examples/extensions/bookmark.ts +50 -0
  147. package/examples/extensions/claude-rules.ts +86 -0
  148. package/examples/extensions/confirm-destructive.ts +59 -0
  149. package/examples/extensions/custom-compaction.ts +115 -0
  150. package/examples/extensions/custom-footer.ts +65 -0
  151. package/examples/extensions/custom-header.ts +73 -0
  152. package/examples/extensions/custom-provider-anthropic/index.ts +605 -0
  153. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  154. package/examples/extensions/custom-provider-anthropic/package.json +19 -0
  155. package/examples/extensions/custom-provider-gitlab-duo/index.ts +350 -0
  156. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -0
  157. package/examples/extensions/custom-provider-gitlab-duo/test.ts +83 -0
  158. package/examples/extensions/dirty-repo-guard.ts +56 -0
  159. package/examples/extensions/doom-overlay/README.md +46 -0
  160. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
  161. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  162. package/examples/extensions/doom-overlay/doom/build.sh +152 -0
  163. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
  164. package/examples/extensions/doom-overlay/doom-component.ts +133 -0
  165. package/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  166. package/examples/extensions/doom-overlay/doom-keys.ts +105 -0
  167. package/examples/extensions/doom-overlay/index.ts +74 -0
  168. package/examples/extensions/doom-overlay/wad-finder.ts +51 -0
  169. package/examples/extensions/event-bus.ts +43 -0
  170. package/examples/extensions/file-trigger.ts +41 -0
  171. package/examples/extensions/git-checkpoint.ts +53 -0
  172. package/examples/extensions/handoff.ts +151 -0
  173. package/examples/extensions/hello.ts +25 -0
  174. package/examples/extensions/inline-bash.ts +94 -0
  175. package/examples/extensions/input-transform.ts +43 -0
  176. package/examples/extensions/interactive-shell.ts +196 -0
  177. package/examples/extensions/mac-system-theme.ts +47 -0
  178. package/examples/extensions/message-renderer.ts +60 -0
  179. package/examples/extensions/modal-editor.ts +86 -0
  180. package/examples/extensions/model-status.ts +31 -0
  181. package/examples/extensions/notify.ts +25 -0
  182. package/examples/extensions/overlay-qa-tests.ts +882 -0
  183. package/examples/extensions/overlay-test.ts +151 -0
  184. package/examples/extensions/permission-gate.ts +34 -0
  185. package/examples/extensions/pirate.ts +47 -0
  186. package/examples/extensions/plan-mode/README.md +65 -0
  187. package/examples/extensions/plan-mode/index.ts +341 -0
  188. package/examples/extensions/plan-mode/utils.ts +168 -0
  189. package/examples/extensions/preset.ts +399 -0
  190. package/examples/extensions/protected-paths.ts +30 -0
  191. package/examples/extensions/qna.ts +120 -0
  192. package/examples/extensions/question.ts +265 -0
  193. package/examples/extensions/questionnaire.ts +428 -0
  194. package/examples/extensions/rainbow-editor.ts +88 -0
  195. package/examples/extensions/sandbox/index.ts +318 -0
  196. package/examples/extensions/sandbox/package-lock.json +92 -0
  197. package/examples/extensions/sandbox/package.json +19 -0
  198. package/examples/extensions/send-user-message.ts +97 -0
  199. package/examples/extensions/session-name.ts +27 -0
  200. package/examples/extensions/shutdown-command.ts +63 -0
  201. package/examples/extensions/snake.ts +344 -0
  202. package/examples/extensions/space-invaders.ts +561 -0
  203. package/examples/extensions/ssh.ts +220 -0
  204. package/examples/extensions/status-line.ts +40 -0
  205. package/examples/extensions/subagent/README.md +172 -0
  206. package/examples/extensions/subagent/agents/planner.md +37 -0
  207. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  208. package/examples/extensions/subagent/agents/scout.md +50 -0
  209. package/examples/extensions/subagent/agents/worker.md +24 -0
  210. package/examples/extensions/subagent/agents.ts +127 -0
  211. package/examples/extensions/subagent/index.ts +964 -0
  212. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  213. package/examples/extensions/subagent/prompts/implement.md +10 -0
  214. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  215. package/examples/extensions/summarize.ts +196 -0
  216. package/examples/extensions/timed-confirm.ts +70 -0
  217. package/examples/extensions/todo.ts +300 -0
  218. package/examples/extensions/tool-override.ts +144 -0
  219. package/examples/extensions/tools.ts +147 -0
  220. package/examples/extensions/trigger-compact.ts +40 -0
  221. package/examples/extensions/truncated-tool.ts +193 -0
  222. package/examples/extensions/widget-placement.ts +17 -0
  223. package/examples/extensions/with-deps/index.ts +36 -0
  224. package/examples/extensions/with-deps/package-lock.json +31 -0
  225. package/examples/extensions/with-deps/package.json +22 -0
  226. package/examples/sdk/01-minimal.ts +22 -0
  227. package/examples/sdk/02-custom-model.ts +50 -0
  228. package/examples/sdk/03-custom-prompt.ts +55 -0
  229. package/examples/sdk/04-skills.ts +46 -0
  230. package/examples/sdk/05-tools.ts +56 -0
  231. package/examples/sdk/06-extensions.ts +88 -0
  232. package/examples/sdk/07-context-files.ts +40 -0
  233. package/examples/sdk/08-prompt-templates.ts +47 -0
  234. package/examples/sdk/09-api-keys-and-oauth.ts +48 -0
  235. package/examples/sdk/10-settings.ts +38 -0
  236. package/examples/sdk/11-sessions.ts +48 -0
  237. package/examples/sdk/12-full-control.ts +82 -0
  238. package/examples/sdk/13-codex-oauth.ts +37 -0
  239. package/examples/sdk/README.md +144 -0
  240. package/package.json +85 -0
@@ -0,0 +1,132 @@
1
+ import * as Diff from "diff";
2
+ import { theme } from "../theme/theme.js";
3
+ /**
4
+ * Parse diff line to extract prefix, line number, and content.
5
+ * Format: "+123 content" or "-123 content" or " 123 content" or " ..."
6
+ */
7
+ function parseDiffLine(line) {
8
+ const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
9
+ if (!match)
10
+ return null;
11
+ return { prefix: match[1], lineNum: match[2], content: match[3] };
12
+ }
13
+ /**
14
+ * Replace tabs with spaces for consistent rendering.
15
+ */
16
+ function replaceTabs(text) {
17
+ return text.replace(/\t/g, " ");
18
+ }
19
+ /**
20
+ * Compute word-level diff and render with inverse on changed parts.
21
+ * Uses diffWords which groups whitespace with adjacent words for cleaner highlighting.
22
+ * Strips leading whitespace from inverse to avoid highlighting indentation.
23
+ */
24
+ function renderIntraLineDiff(oldContent, newContent) {
25
+ const wordDiff = Diff.diffWords(oldContent, newContent);
26
+ let removedLine = "";
27
+ let addedLine = "";
28
+ let isFirstRemoved = true;
29
+ let isFirstAdded = true;
30
+ for (const part of wordDiff) {
31
+ if (part.removed) {
32
+ let value = part.value;
33
+ // Strip leading whitespace from the first removed part
34
+ if (isFirstRemoved) {
35
+ const leadingWs = value.match(/^(\s*)/)?.[1] || "";
36
+ value = value.slice(leadingWs.length);
37
+ removedLine += leadingWs;
38
+ isFirstRemoved = false;
39
+ }
40
+ if (value) {
41
+ removedLine += theme.inverse(value);
42
+ }
43
+ }
44
+ else if (part.added) {
45
+ let value = part.value;
46
+ // Strip leading whitespace from the first added part
47
+ if (isFirstAdded) {
48
+ const leadingWs = value.match(/^(\s*)/)?.[1] || "";
49
+ value = value.slice(leadingWs.length);
50
+ addedLine += leadingWs;
51
+ isFirstAdded = false;
52
+ }
53
+ if (value) {
54
+ addedLine += theme.inverse(value);
55
+ }
56
+ }
57
+ else {
58
+ removedLine += part.value;
59
+ addedLine += part.value;
60
+ }
61
+ }
62
+ return { removedLine, addedLine };
63
+ }
64
+ /**
65
+ * Render a diff string with colored lines and intra-line change highlighting.
66
+ * - Context lines: dim/gray
67
+ * - Removed lines: red, with inverse on changed tokens
68
+ * - Added lines: green, with inverse on changed tokens
69
+ */
70
+ export function renderDiff(diffText, _options = {}) {
71
+ const lines = diffText.split("\n");
72
+ const result = [];
73
+ let i = 0;
74
+ while (i < lines.length) {
75
+ const line = lines[i];
76
+ const parsed = parseDiffLine(line);
77
+ if (!parsed) {
78
+ result.push(theme.fg("toolDiffContext", line));
79
+ i++;
80
+ continue;
81
+ }
82
+ if (parsed.prefix === "-") {
83
+ // Collect consecutive removed lines
84
+ const removedLines = [];
85
+ while (i < lines.length) {
86
+ const p = parseDiffLine(lines[i]);
87
+ if (!p || p.prefix !== "-")
88
+ break;
89
+ removedLines.push({ lineNum: p.lineNum, content: p.content });
90
+ i++;
91
+ }
92
+ // Collect consecutive added lines
93
+ const addedLines = [];
94
+ while (i < lines.length) {
95
+ const p = parseDiffLine(lines[i]);
96
+ if (!p || p.prefix !== "+")
97
+ break;
98
+ addedLines.push({ lineNum: p.lineNum, content: p.content });
99
+ i++;
100
+ }
101
+ // Only do intra-line diffing when there's exactly one removed and one added line
102
+ // (indicating a single line modification). Otherwise, show lines as-is.
103
+ if (removedLines.length === 1 && addedLines.length === 1) {
104
+ const removed = removedLines[0];
105
+ const added = addedLines[0];
106
+ const { removedLine, addedLine } = renderIntraLineDiff(replaceTabs(removed.content), replaceTabs(added.content));
107
+ result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${removedLine}`));
108
+ result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${addedLine}`));
109
+ }
110
+ else {
111
+ // Show all removed lines first, then all added lines
112
+ for (const removed of removedLines) {
113
+ result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`));
114
+ }
115
+ for (const added of addedLines) {
116
+ result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`));
117
+ }
118
+ }
119
+ }
120
+ else if (parsed.prefix === "+") {
121
+ // Standalone added line
122
+ result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`));
123
+ i++;
124
+ }
125
+ else {
126
+ // Context line
127
+ result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`));
128
+ i++;
129
+ }
130
+ }
131
+ return result.join("\n");
132
+ }
@@ -0,0 +1,19 @@
1
+ import { theme } from "../theme/theme.js";
2
+ /**
3
+ * Dynamic border component that adjusts to viewport width.
4
+ *
5
+ * Note: When used from extensions loaded via jiti, the global `theme` may be undefined
6
+ * because jiti creates a separate module cache. Always pass an explicit color
7
+ * function when using DynamicBorder in components exported for extension use.
8
+ */
9
+ export class DynamicBorder {
10
+ constructor(color = (str) => theme.fg("border", str)) {
11
+ this.color = color;
12
+ }
13
+ invalidate() {
14
+ // No cached state to invalidate currently
15
+ }
16
+ render(width) {
17
+ return [this.color("─".repeat(Math.max(1, width)))];
18
+ }
19
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Multi-line editor component for extensions.
3
+ * Supports Ctrl+G for external editor.
4
+ */
5
+ import { spawnSync } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { Container, Editor, getEditorKeybindings, Spacer, Text, } from "indusagi/tui";
10
+ import { getEditorTheme, theme } from "../theme/theme.js";
11
+ import { DynamicBorder } from "./dynamic-border.js";
12
+ import { appKeyHint, keyHint } from "./keybinding-hints.js";
13
+ export class ExtensionEditorComponent extends Container {
14
+ constructor(tui, keybindings, title, prefill, onSubmit, onCancel, options) {
15
+ super();
16
+ this.tui = tui;
17
+ this.keybindings = keybindings;
18
+ this.onSubmitCallback = onSubmit;
19
+ this.onCancelCallback = onCancel;
20
+ // Add top border
21
+ this.addChild(new DynamicBorder());
22
+ this.addChild(new Spacer(1));
23
+ // Add title
24
+ this.addChild(new Text(theme.fg("accent", title), 1, 0));
25
+ this.addChild(new Spacer(1));
26
+ // Create editor
27
+ this.editor = new Editor(tui, getEditorTheme(), options);
28
+ if (prefill) {
29
+ this.editor.setText(prefill);
30
+ }
31
+ // Wire up Enter to submit (Shift+Enter for newlines, like the main editor)
32
+ this.editor.onSubmit = (text) => {
33
+ this.onSubmitCallback(text);
34
+ };
35
+ this.addChild(this.editor);
36
+ this.addChild(new Spacer(1));
37
+ // Add hint
38
+ const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR);
39
+ const hint = keyHint("selectConfirm", "submit") +
40
+ " " +
41
+ keyHint("newLine", "newline") +
42
+ " " +
43
+ keyHint("selectCancel", "cancel") +
44
+ (hasExternalEditor ? ` ${appKeyHint(this.keybindings, "externalEditor", "external editor")}` : "");
45
+ this.addChild(new Text(hint, 1, 0));
46
+ this.addChild(new Spacer(1));
47
+ // Add bottom border
48
+ this.addChild(new DynamicBorder());
49
+ }
50
+ handleInput(keyData) {
51
+ const kb = getEditorKeybindings();
52
+ // Escape or Ctrl+C to cancel
53
+ if (kb.matches(keyData, "selectCancel")) {
54
+ this.onCancelCallback();
55
+ return;
56
+ }
57
+ // External editor (app keybinding)
58
+ if (this.keybindings.matches(keyData, "externalEditor")) {
59
+ this.openExternalEditor();
60
+ return;
61
+ }
62
+ // Forward to editor
63
+ this.editor.handleInput(keyData);
64
+ }
65
+ openExternalEditor() {
66
+ const editorCmd = process.env.VISUAL || process.env.EDITOR;
67
+ if (!editorCmd) {
68
+ return;
69
+ }
70
+ const currentText = this.editor.getText();
71
+ const tmpFile = path.join(os.tmpdir(), `indusagi-extension-editor-${Date.now()}.md`);
72
+ try {
73
+ fs.writeFileSync(tmpFile, currentText, "utf-8");
74
+ this.tui.stop();
75
+ const [editor, ...editorArgs] = editorCmd.split(" ");
76
+ const result = spawnSync(editor, [...editorArgs, tmpFile], {
77
+ stdio: "inherit",
78
+ });
79
+ if (result.status === 0) {
80
+ const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
81
+ this.editor.setText(newContent);
82
+ }
83
+ }
84
+ finally {
85
+ try {
86
+ fs.unlinkSync(tmpFile);
87
+ }
88
+ catch {
89
+ // Ignore cleanup errors
90
+ }
91
+ this.tui.start();
92
+ // Force full re-render since external editor uses alternate screen
93
+ this.tui.requestRender(true);
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Simple text input component for extensions.
3
+ */
4
+ import { Container, getEditorKeybindings, Input, Spacer, Text } from "indusagi/tui";
5
+ import { theme } from "../theme/theme.js";
6
+ import { CountdownTimer } from "./countdown-timer.js";
7
+ import { DynamicBorder } from "./dynamic-border.js";
8
+ import { keyHint } from "./keybinding-hints.js";
9
+ export class ExtensionInputComponent extends Container {
10
+ get focused() {
11
+ return this._focused;
12
+ }
13
+ set focused(value) {
14
+ this._focused = value;
15
+ this.input.focused = value;
16
+ }
17
+ constructor(title, _placeholder, onSubmit, onCancel, opts) {
18
+ super();
19
+ // Focusable implementation - propagate to input for IME cursor positioning
20
+ this._focused = false;
21
+ this.onSubmitCallback = onSubmit;
22
+ this.onCancelCallback = onCancel;
23
+ this.baseTitle = title;
24
+ this.addChild(new DynamicBorder());
25
+ this.addChild(new Spacer(1));
26
+ this.titleText = new Text(theme.fg("accent", title), 1, 0);
27
+ this.addChild(this.titleText);
28
+ this.addChild(new Spacer(1));
29
+ if (opts?.timeout && opts.timeout > 0 && opts.tui) {
30
+ this.countdown = new CountdownTimer(opts.timeout, opts.tui, (s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)), () => this.onCancelCallback());
31
+ }
32
+ this.input = new Input();
33
+ this.addChild(this.input);
34
+ this.addChild(new Spacer(1));
35
+ this.addChild(new Text(`${keyHint("selectConfirm", "submit")} ${keyHint("selectCancel", "cancel")}`, 1, 0));
36
+ this.addChild(new Spacer(1));
37
+ this.addChild(new DynamicBorder());
38
+ }
39
+ handleInput(keyData) {
40
+ const kb = getEditorKeybindings();
41
+ if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
42
+ this.onSubmitCallback(this.input.getValue());
43
+ }
44
+ else if (kb.matches(keyData, "selectCancel")) {
45
+ this.onCancelCallback();
46
+ }
47
+ else {
48
+ this.input.handleInput(keyData);
49
+ }
50
+ }
51
+ dispose() {
52
+ this.countdown?.dispose();
53
+ }
54
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Generic selector component for extensions.
3
+ * Displays a list of string options with keyboard navigation.
4
+ */
5
+ import { Container, getEditorKeybindings, Spacer, Text } from "indusagi/tui";
6
+ import { theme } from "../theme/theme.js";
7
+ import { CountdownTimer } from "./countdown-timer.js";
8
+ import { DynamicBorder } from "./dynamic-border.js";
9
+ import { keyHint, rawKeyHint } from "./keybinding-hints.js";
10
+ export class ExtensionSelectorComponent extends Container {
11
+ constructor(title, options, onSelect, onCancel, opts) {
12
+ super();
13
+ this.selectedIndex = 0;
14
+ this.options = options;
15
+ this.onSelectCallback = onSelect;
16
+ this.onCancelCallback = onCancel;
17
+ this.baseTitle = title;
18
+ this.addChild(new DynamicBorder());
19
+ this.addChild(new Spacer(1));
20
+ this.titleText = new Text(theme.fg("accent", title), 1, 0);
21
+ this.addChild(this.titleText);
22
+ this.addChild(new Spacer(1));
23
+ if (opts?.timeout && opts.timeout > 0 && opts.tui) {
24
+ this.countdown = new CountdownTimer(opts.timeout, opts.tui, (s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)), () => this.onCancelCallback());
25
+ }
26
+ this.listContainer = new Container();
27
+ this.addChild(this.listContainer);
28
+ this.addChild(new Spacer(1));
29
+ this.addChild(new Text(rawKeyHint("↑↓", "navigate") +
30
+ " " +
31
+ keyHint("selectConfirm", "select") +
32
+ " " +
33
+ keyHint("selectCancel", "cancel"), 1, 0));
34
+ this.addChild(new Spacer(1));
35
+ this.addChild(new DynamicBorder());
36
+ this.updateList();
37
+ }
38
+ updateList() {
39
+ this.listContainer.clear();
40
+ for (let i = 0; i < this.options.length; i++) {
41
+ const isSelected = i === this.selectedIndex;
42
+ const text = isSelected
43
+ ? theme.fg("accent", "→ ") + theme.fg("accent", this.options[i])
44
+ : ` ${theme.fg("text", this.options[i])}`;
45
+ this.listContainer.addChild(new Text(text, 1, 0));
46
+ }
47
+ }
48
+ handleInput(keyData) {
49
+ const kb = getEditorKeybindings();
50
+ if (kb.matches(keyData, "selectUp") || keyData === "k") {
51
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
52
+ this.updateList();
53
+ }
54
+ else if (kb.matches(keyData, "selectDown") || keyData === "j") {
55
+ this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1);
56
+ this.updateList();
57
+ }
58
+ else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
59
+ const selected = this.options[this.selectedIndex];
60
+ if (selected)
61
+ this.onSelectCallback(selected);
62
+ }
63
+ else if (kb.matches(keyData, "selectCancel")) {
64
+ this.onCancelCallback();
65
+ }
66
+ }
67
+ dispose() {
68
+ this.countdown?.dispose();
69
+ }
70
+ }
@@ -0,0 +1,213 @@
1
+ import { truncateToWidth, visibleWidth } from "indusagi/tui";
2
+ import { theme } from "../theme/theme.js";
3
+ /**
4
+ * Sanitize text for display in a single-line status.
5
+ * Removes newlines, tabs, carriage returns, and other control characters.
6
+ */
7
+ function sanitizeStatusText(text) {
8
+ // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
9
+ return text
10
+ .replace(/[\r\n\t]/g, " ")
11
+ .replace(/ +/g, " ")
12
+ .trim();
13
+ }
14
+ /**
15
+ * Format token counts (similar to web-ui)
16
+ */
17
+ function formatTokens(count) {
18
+ if (count < 1000)
19
+ return count.toString();
20
+ if (count < 10000)
21
+ return `${(count / 1000).toFixed(1)}k`;
22
+ if (count < 1000000)
23
+ return `${Math.round(count / 1000)}k`;
24
+ if (count < 10000000)
25
+ return `${(count / 1000000).toFixed(1)}M`;
26
+ return `${Math.round(count / 1000000)}M`;
27
+ }
28
+ /**
29
+ * Footer component that shows pwd, token stats, and context usage.
30
+ * Computes token/context stats from session, gets git branch and extension statuses from provider.
31
+ */
32
+ export class FooterComponent {
33
+ constructor(session, footerData) {
34
+ this.session = session;
35
+ this.footerData = footerData;
36
+ this.autoCompactEnabled = true;
37
+ }
38
+ setAutoCompactEnabled(enabled) {
39
+ this.autoCompactEnabled = enabled;
40
+ }
41
+ /**
42
+ * No-op: git branch caching now handled by provider.
43
+ * Kept for compatibility with existing call sites in interactive-mode.
44
+ */
45
+ invalidate() {
46
+ // No-op: git branch is cached/invalidated by provider
47
+ }
48
+ /**
49
+ * Clean up resources.
50
+ * Git watcher cleanup now handled by provider.
51
+ */
52
+ dispose() {
53
+ // Git watcher cleanup handled by provider
54
+ }
55
+ render(width) {
56
+ const state = this.session.state;
57
+ // Calculate cumulative usage from ALL session entries (not just post-compaction messages)
58
+ let totalInput = 0;
59
+ let totalOutput = 0;
60
+ let totalCacheRead = 0;
61
+ let totalCacheWrite = 0;
62
+ let totalCost = 0;
63
+ for (const entry of this.session.sessionManager.getEntries()) {
64
+ if (entry.type === "message" && entry.message.role === "assistant") {
65
+ totalInput += entry.message.usage.input;
66
+ totalOutput += entry.message.usage.output;
67
+ totalCacheRead += entry.message.usage.cacheRead;
68
+ totalCacheWrite += entry.message.usage.cacheWrite;
69
+ totalCost += entry.message.usage.cost.total;
70
+ }
71
+ }
72
+ // Get last assistant message for context percentage calculation (skip aborted messages)
73
+ const lastAssistantMessage = state.messages
74
+ .slice()
75
+ .reverse()
76
+ .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
77
+ // Calculate context percentage from last message (input + output + cacheRead + cacheWrite)
78
+ const contextTokens = lastAssistantMessage
79
+ ? lastAssistantMessage.usage.input +
80
+ lastAssistantMessage.usage.output +
81
+ lastAssistantMessage.usage.cacheRead +
82
+ lastAssistantMessage.usage.cacheWrite
83
+ : 0;
84
+ const contextWindow = state.model?.contextWindow || 0;
85
+ const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
86
+ const contextPercent = contextPercentValue.toFixed(1);
87
+ // Replace home directory with ~
88
+ let pwd = process.cwd();
89
+ const home = process.env.HOME || process.env.USERPROFILE;
90
+ if (home && pwd.startsWith(home)) {
91
+ pwd = `~${pwd.slice(home.length)}`;
92
+ }
93
+ // Add git branch if available
94
+ const branch = this.footerData.getGitBranch();
95
+ if (branch) {
96
+ pwd = `${pwd} (${branch})`;
97
+ }
98
+ // Add session name if set
99
+ const sessionName = this.session.sessionManager.getSessionName();
100
+ if (sessionName) {
101
+ pwd = `${pwd} • ${sessionName}`;
102
+ }
103
+ // Truncate path if too long to fit width
104
+ if (pwd.length > width) {
105
+ const half = Math.floor(width / 2) - 2;
106
+ if (half > 0) {
107
+ const start = pwd.slice(0, half);
108
+ const end = pwd.slice(-(half - 1));
109
+ pwd = `${start}...${end}`;
110
+ }
111
+ else {
112
+ pwd = pwd.slice(0, Math.max(1, width));
113
+ }
114
+ }
115
+ // Build stats line
116
+ const statsParts = [];
117
+ if (totalInput)
118
+ statsParts.push(`↑${formatTokens(totalInput)}`);
119
+ if (totalOutput)
120
+ statsParts.push(`↓${formatTokens(totalOutput)}`);
121
+ if (totalCacheRead)
122
+ statsParts.push(`R${formatTokens(totalCacheRead)}`);
123
+ if (totalCacheWrite)
124
+ statsParts.push(`W${formatTokens(totalCacheWrite)}`);
125
+ // Show cost with "(sub)" indicator if using OAuth subscription
126
+ const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
127
+ if (totalCost || usingSubscription) {
128
+ const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
129
+ statsParts.push(costStr);
130
+ }
131
+ // Colorize context percentage based on usage
132
+ let contextPercentStr;
133
+ const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
134
+ const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
135
+ if (contextPercentValue > 90) {
136
+ contextPercentStr = theme.fg("error", contextPercentDisplay);
137
+ }
138
+ else if (contextPercentValue > 70) {
139
+ contextPercentStr = theme.fg("warning", contextPercentDisplay);
140
+ }
141
+ else {
142
+ contextPercentStr = contextPercentDisplay;
143
+ }
144
+ statsParts.push(contextPercentStr);
145
+ let statsLeft = statsParts.join(" ");
146
+ // Add model name on the right side, plus thinking level if model supports it
147
+ const modelName = state.model?.id || "no-model";
148
+ // Add thinking level hint if model supports reasoning and thinking is enabled
149
+ let rightSide = modelName;
150
+ if (state.model?.reasoning) {
151
+ const thinkingLevel = state.thinkingLevel || "off";
152
+ if (thinkingLevel !== "off") {
153
+ rightSide = `${modelName} • ${thinkingLevel}`;
154
+ }
155
+ }
156
+ // Prepend the provider in parenthesis to the right side if there's multiple providers
157
+ if (this.footerData.getAvailableProviderCount() > 1 && state.model) {
158
+ rightSide = `(${state.model.provider}) ${rightSide}`;
159
+ }
160
+ let statsLeftWidth = visibleWidth(statsLeft);
161
+ const rightSideWidth = visibleWidth(rightSide);
162
+ // If statsLeft is too wide, truncate it
163
+ if (statsLeftWidth > width) {
164
+ // Truncate statsLeft to fit width (no room for right side)
165
+ const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
166
+ statsLeft = `${plainStatsLeft.substring(0, width - 3)}...`;
167
+ statsLeftWidth = visibleWidth(statsLeft);
168
+ }
169
+ // Calculate available space for padding (minimum 2 spaces between stats and model)
170
+ const minPadding = 2;
171
+ const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
172
+ let statsLine;
173
+ if (totalNeeded <= width) {
174
+ // Both fit - add padding to right-align model
175
+ const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
176
+ statsLine = statsLeft + padding + rightSide;
177
+ }
178
+ else {
179
+ // Need to truncate right side
180
+ const availableForRight = width - statsLeftWidth - minPadding;
181
+ if (availableForRight > 3) {
182
+ // Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)
183
+ const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
184
+ const truncatedPlain = plainRightSide.substring(0, availableForRight);
185
+ // For simplicity, just use plain truncated version (loses color, but fits)
186
+ const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length);
187
+ statsLine = statsLeft + padding + truncatedPlain;
188
+ }
189
+ else {
190
+ // Not enough space for right side at all
191
+ statsLine = statsLeft;
192
+ }
193
+ }
194
+ // Apply dim to each part separately. statsLeft may contain color codes (for context %)
195
+ // that end with a reset, which would clear an outer dim wrapper. So we dim the parts
196
+ // before and after the colored section independently.
197
+ const dimStatsLeft = theme.fg("dim", statsLeft);
198
+ const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
199
+ const dimRemainder = theme.fg("dim", remainder);
200
+ const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
201
+ // Add extension statuses on a single line, sorted by key alphabetically
202
+ const extensionStatuses = this.footerData.getExtensionStatuses();
203
+ if (extensionStatuses.size > 0) {
204
+ const sortedStatuses = Array.from(extensionStatuses.entries())
205
+ .sort(([a], [b]) => a.localeCompare(b))
206
+ .map(([, text]) => sanitizeStatusText(text));
207
+ const statusLine = sortedStatuses.join(" ");
208
+ // Truncate to terminal width with dim ellipsis for consistency with footer style
209
+ lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
210
+ }
211
+ return lines;
212
+ }
213
+ }
@@ -0,0 +1,31 @@
1
+ // UI Components for extensions
2
+ export { ArminComponent } from "./armin.js";
3
+ export { AssistantMessageComponent } from "./assistant-message.js";
4
+ export { BashExecutionComponent } from "./bash-execution.js";
5
+ export { BorderedLoader } from "./bordered-loader.js";
6
+ export { BranchSummaryMessageComponent } from "./branch-summary-message.js";
7
+ export { CompactionSummaryMessageComponent } from "./compaction-summary-message.js";
8
+ export { CustomEditor } from "./custom-editor.js";
9
+ export { CustomMessageComponent } from "./custom-message.js";
10
+ export { renderDiff } from "./diff.js";
11
+ export { DynamicBorder } from "./dynamic-border.js";
12
+ export { ExtensionEditorComponent } from "./extension-editor.js";
13
+ export { ExtensionInputComponent } from "./extension-input.js";
14
+ export { ExtensionSelectorComponent } from "./extension-selector.js";
15
+ export { FooterComponent } from "./footer.js";
16
+ export { appKey, appKeyHint, editorKey, keyHint, rawKeyHint } from "./keybinding-hints.js";
17
+ export { LoginDialogComponent } from "./login-dialog.js";
18
+ export { ModelSelectorComponent } from "./model-selector.js";
19
+ export { OAuthSelectorComponent } from "./oauth-selector.js";
20
+ export { ScopedModelsSelectorComponent } from "./scoped-models-selector.js";
21
+ export { SessionSelectorComponent } from "./session-selector.js";
22
+ export { SettingsSelectorComponent } from "./settings-selector.js";
23
+ export { ShowImagesSelectorComponent } from "./show-images-selector.js";
24
+ export { SkillInvocationMessageComponent } from "./skill-invocation-message.js";
25
+ export { ThemeSelectorComponent } from "./theme-selector.js";
26
+ export { ThinkingSelectorComponent } from "./thinking-selector.js";
27
+ export { ToolExecutionComponent } from "./tool-execution.js";
28
+ export { TreeSelectorComponent } from "./tree-selector.js";
29
+ export { UserMessageComponent } from "./user-message.js";
30
+ export { UserMessageSelectorComponent } from "./user-message-selector.js";
31
+ export { truncateToVisualLines } from "./visual-truncate.js";
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Utilities for formatting keybinding hints in the UI.
3
+ */
4
+ import { getEditorKeybindings } from "indusagi/tui";
5
+ import { theme } from "../theme/theme.js";
6
+ /**
7
+ * Format keys array as display string (e.g., ["ctrl+c", "escape"] -> "ctrl+c/escape").
8
+ */
9
+ function formatKeys(keys) {
10
+ if (keys.length === 0)
11
+ return "";
12
+ if (keys.length === 1)
13
+ return keys[0];
14
+ return keys.join("/");
15
+ }
16
+ /**
17
+ * Get display string for an editor action.
18
+ */
19
+ export function editorKey(action) {
20
+ return formatKeys(getEditorKeybindings().getKeys(action));
21
+ }
22
+ /**
23
+ * Get display string for an app action.
24
+ */
25
+ export function appKey(keybindings, action) {
26
+ return formatKeys(keybindings.getKeys(action));
27
+ }
28
+ /**
29
+ * Format a keybinding hint with consistent styling: dim key, muted description.
30
+ * Looks up the key from editor keybindings automatically.
31
+ *
32
+ * @param action - Editor action name (e.g., "selectConfirm", "expandTools")
33
+ * @param description - Description text (e.g., "to expand", "cancel")
34
+ * @returns Formatted string with dim key and muted description
35
+ */
36
+ export function keyHint(action, description) {
37
+ return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`);
38
+ }
39
+ /**
40
+ * Format a keybinding hint for app-level actions.
41
+ * Requires the KeybindingsManager instance.
42
+ *
43
+ * @param keybindings - KeybindingsManager instance
44
+ * @param action - App action name (e.g., "interrupt", "externalEditor")
45
+ * @param description - Description text
46
+ * @returns Formatted string with dim key and muted description
47
+ */
48
+ export function appKeyHint(keybindings, action, description) {
49
+ return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`);
50
+ }
51
+ /**
52
+ * Format a raw key string with description (for non-configurable keys like ↑↓).
53
+ *
54
+ * @param key - Raw key string
55
+ * @param description - Description text
56
+ * @returns Formatted string with dim key and muted description
57
+ */
58
+ export function rawKeyHint(key, description) {
59
+ return theme.fg("dim", key) + theme.fg("muted", ` ${description}`);
60
+ }