@falkenslab/agent-kit 0.14.0 → 0.16.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 (70) hide show
  1. package/README.md +110 -110
  2. package/assets/knowledge-plugin/.claude-plugin/plugin.json +1 -1
  3. package/assets/knowledge-plugin/commands/ingest.md +1 -1
  4. package/assets/knowledge-plugin/commands/lint.md +1 -1
  5. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +13 -19
  6. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +18 -14
  7. package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +5 -5
  8. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +4 -0
  9. package/assets/knowledge-plugin-files/commands/ingest.md +7 -0
  10. package/assets/knowledge-plugin-files/commands/lint.md +5 -0
  11. package/assets/knowledge-plugin-files/commands/query.md +5 -0
  12. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +46 -0
  13. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +26 -0
  14. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +14 -0
  15. package/dist/core/agentSpec.d.ts +27 -2
  16. package/dist/core/extractText.d.ts +13 -0
  17. package/dist/core/extractText.js +147 -0
  18. package/dist/core/fileKnowledgeStore.d.ts +10 -0
  19. package/dist/core/fileKnowledgeStore.js +378 -0
  20. package/dist/core/hooks/fileScopeGate.d.ts +8 -0
  21. package/dist/core/hooks/fileScopeGate.js +20 -0
  22. package/dist/core/hooks/humanInput.d.ts +18 -0
  23. package/dist/core/hooks/humanInput.js +30 -2
  24. package/dist/core/hooks/planGate.js +14 -5
  25. package/dist/core/hooks/stepGate.js +6 -0
  26. package/dist/core/interaction.d.ts +20 -0
  27. package/dist/core/knowledge.d.ts +14 -4
  28. package/dist/core/knowledge.js +36 -6
  29. package/dist/core/knowledgeStore.d.ts +134 -0
  30. package/dist/core/knowledgeStore.js +84 -0
  31. package/dist/core/messages/de.js +65 -0
  32. package/dist/core/messages/en.d.ts +41 -0
  33. package/dist/core/messages/en.js +65 -0
  34. package/dist/core/messages/es.js +65 -0
  35. package/dist/core/messages/fr.js +65 -0
  36. package/dist/core/modeControl.d.ts +36 -0
  37. package/dist/core/modeControl.js +61 -0
  38. package/dist/core/session.d.ts +6 -31
  39. package/dist/core/session.js +76 -71
  40. package/dist/core/sources.d.ts +122 -0
  41. package/dist/core/sources.js +312 -0
  42. package/dist/core/todos.d.ts +23 -0
  43. package/dist/core/todos.js +33 -0
  44. package/dist/core/toolLabels.d.ts +1 -1
  45. package/dist/core/toolLabels.js +42 -2
  46. package/dist/core/tools/humanApproval.d.ts +9 -5
  47. package/dist/core/tools/humanApproval.js +39 -7
  48. package/dist/core/tools/knowledgeTools.d.ts +16 -0
  49. package/dist/core/tools/knowledgeTools.js +161 -0
  50. package/dist/core/tools/saveToSources.d.ts +27 -8
  51. package/dist/core/tools/saveToSources.js +203 -55
  52. package/dist/core/tools/time.d.ts +29 -0
  53. package/dist/core/tools/time.js +129 -0
  54. package/dist/index.d.ts +7 -4
  55. package/dist/index.js +4 -1
  56. package/dist/tui/chatTui.js +14 -1
  57. package/dist/tui/consoleRenderer.js +16 -2
  58. package/dist/tui/ink/SessionView.js +72 -17
  59. package/dist/tui/ink/inkInteraction.d.ts +4 -2
  60. package/dist/tui/ink/inkInteraction.js +20 -2
  61. package/dist/tui/ink/progressView.d.ts +5 -1
  62. package/dist/tui/ink/runChatInk.d.ts +16 -1
  63. package/dist/tui/ink/runChatInk.js +7 -0
  64. package/dist/tui/ink/sessionModel.d.ts +10 -3
  65. package/dist/tui/ink/sessionModel.js +26 -4
  66. package/dist/tui/ink/toolGroup.d.ts +23 -2
  67. package/dist/tui/ink/toolGroup.js +17 -4
  68. package/dist/tui/terminalInteraction.js +2 -0
  69. package/package.json +31 -1
  70. /package/assets/{knowledge-plugin → knowledge-plugin-files}/skills/knowledge-pages/SKILL.md +0 -0
@@ -2,7 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import wrapAnsi from "wrap-ansi";
3
3
  import { useEffect, useReducer, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { Box, measureElement, Static, Text, useInput, useStdout } from "ink";
5
- import { Select } from "@inkjs/ui";
5
+ import { MultiSelect, Select, TextInput } from "@inkjs/ui";
6
6
  import { liveWidth } from "./sessionModel.js";
7
7
  import { createRowCache, highlightRow, isMouseReport, mouseEvents, rowsBelow, scrollBy, selectedText, visibleRows, userBarRows, } from "./fullscreen.js";
8
8
  import { framePosition } from "./terminalCursor.js";
@@ -11,6 +11,7 @@ import { fitWidth, stripAnsi } from "./lineBuffer.js";
11
11
  import * as ui from "../ui.js";
12
12
  import { t } from "../../core/messages/index.js";
13
13
  import { inkColor } from "../theme.js";
14
+ import { renderMarkdown } from "./markdown.js";
14
15
  // Numbered as in the Claude Code CLI's permission prompt; the digits and y/n/q answer too.
15
16
  const decisionOptions = () => [
16
17
  { label: `1. ${t().yes}`, value: "y" },
@@ -44,16 +45,42 @@ export function previewLines(lines, terminalRows, reservedRows = PANEL_RESERVED_
44
45
  }
45
46
  function CheckpointPanel({ checkpoint, renderApproval, reservedRows, width, }) {
46
47
  const decision = checkpoint.kind === "decision";
47
- useInput((input) => {
48
- const key = input.toLowerCase();
49
- if (decision && DECISION_KEYS[key])
50
- checkpoint.answer(DECISION_KEYS[key]);
51
- else if (!decision && key === "1")
48
+ const text = checkpoint.kind === "text";
49
+ const choice = checkpoint.kind === "choice";
50
+ useInput((input, key) => {
51
+ if (text || choice) {
52
+ if (key.escape)
53
+ checkpoint.answer("");
54
+ return;
55
+ }
56
+ const lower = input.toLowerCase();
57
+ if (decision && DECISION_KEYS[lower])
58
+ checkpoint.answer(DECISION_KEYS[lower]);
59
+ else if (!decision && lower === "1")
52
60
  checkpoint.answer("");
53
61
  });
54
62
  const { prompt } = checkpoint;
55
63
  const { stdout } = useStdout();
56
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("accent"), paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.lines, stdout.rows, reservedRows, width - PANEL_FRAME_COLUMNS).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: t().proceed }) })) : null, _jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? decisionOptions() : manualOptions(), onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) })] }));
64
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("accent"), paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.markdown ? renderMarkdown(prompt.lines.join("\n"), width - PANEL_FRAME_COLUMNS) : prompt.lines, stdout.rows, reservedRows, width - PANEL_FRAME_COLUMNS).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: t().proceed }) })) : null, choice && checkpoint.choice ? (_jsx(ChoiceInput, { choice: checkpoint.choice, onAnswer: (value) => checkpoint.answer(value) }, checkpoint.id)) : text ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: "> " }), _jsx(TextInput, { placeholder: t().textAnswerHint, onSubmit: (value) => checkpoint.answer(value.trim()) }, checkpoint.id)] }), _jsx(Text, { dimColor: true, children: t().textAnswerKeys })] })) : (_jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? decisionOptions() : manualOptions(), onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) }))] }));
65
+ }
66
+ /**
67
+ * A choice's options (one with Select, several with MultiSelect), plus "Other" to type an
68
+ * answer of one's own; answers as `InteractionPort.askChoice()` says: the numbers, then the
69
+ * typed answer on the next line.
70
+ */
71
+ function ChoiceInput({ choice, onAnswer }) {
72
+ const [typing, setTyping] = useState(null);
73
+ const options = [...choice.options.map((label, i) => ({ label: `${i + 1}. ${label}`, value: String(i + 1) })), { label: t().otherOption, value: "other" }];
74
+ if (typing !== null) {
75
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: "> " }), _jsx(TextInput, { placeholder: t().textAnswerHint, onSubmit: (value) => onAnswer([typing, value.trim()].filter(Boolean).join("\n")) })] }), _jsx(Text, { dimColor: true, children: t().textAnswerKeys })] }));
76
+ }
77
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [choice.multiple ? (_jsx(MultiSelect, { options: options, onSubmit: (values) => {
78
+ const picked = values.filter((value) => value !== "other").join(",");
79
+ if (values.includes("other"))
80
+ setTyping(picked);
81
+ else
82
+ onAnswer(picked);
83
+ } })) : (_jsx(Select, { options: options, onChange: (value) => (value === "other" ? setTyping("") : onAnswer(value)) })), _jsx(Text, { dimColor: true, children: t().choiceKeys(choice.multiple) })] }));
57
84
  }
58
85
  function formatTokens(count) {
59
86
  return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
@@ -89,7 +116,21 @@ function LiveArea({ session, checkpoint, renderApproval, mode, width, statusExtr
89
116
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, marginTop: session.liveGap ? 1 : 0, children: [stripAnsi(session.live) ? _jsx(Text, { children: session.live }) : null, checkpoint ? (_jsx(CheckpointPanel, { checkpoint: checkpoint, renderApproval: renderApproval, reservedRows: reservedRows, width: width })) : session.busy ? (
90
117
  // Always one blank line above the spinner: from this margin, or from the live area's
91
118
  // own when a separator is pending and nothing is written above the spinner yet.
92
- _jsxs(Box, { flexDirection: "column", marginTop: stripAnsi(session.live) || !session.liveGap ? 1 : 0, children: [_jsx(Working, { label: session.activity ?? t().thinking, startedAt: session.turnStartedAt, width: width }), session.subagentActivity ? _jsx(Text, { dimColor: true, children: fitWidth(` ↳ ${session.subagentActivity}`, width) }) : null] })) : null, checkpoint ? null : children, _jsx(Text, { children: fitWidth(status, width) })] }));
119
+ _jsxs(Box, { flexDirection: "column", marginTop: stripAnsi(session.live) || !session.liveGap ? 1 : 0, children: [_jsx(Working, { label: session.activity ?? t().thinking, startedAt: session.turnStartedAt, width: width }), session.subagentActivity ? _jsx(Text, { dimColor: true, children: fitWidth(` ↳ ${session.subagentActivity}`, width) }) : null] })) : null, checkpoint || !session.todos ? null : _jsx(TodoList, { todos: session.todos, width: width }), checkpoint ? null : children, _jsx(Text, { children: fitWidth(status, width) })] }));
120
+ }
121
+ // At most this many tasks show; the rest are counted (the one in progress always shows).
122
+ const MAX_TODOS = 8;
123
+ /** The agent's task list (TodoWrite), as in the Claude Code CLI: `☐` pending, `◼` in progress, `☑` done. */
124
+ function TodoList({ todos, width }) {
125
+ const current = todos.findIndex((todo) => todo.status === "in_progress");
126
+ const first = Math.max(0, Math.min(current - 2, todos.length - MAX_TODOS));
127
+ const shown = todos.slice(first, first + MAX_TODOS);
128
+ const hidden = todos.length - shown.length;
129
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [shown.map((todo, i) => {
130
+ const line = ` ${i === 0 ? "⎿ " : " "}${todo.status === "completed" ? "☑" : todo.status === "in_progress" ? "◼" : "☐"} ${todo.content}`;
131
+ const fitted = fitWidth(line, width);
132
+ return (_jsx(Text, { children: todo.status === "completed" ? ui.dim(ui.strike(fitted)) : todo.status === "in_progress" ? ui.bold(fitted) : fitted }, first + i));
133
+ }), hidden > 0 ? _jsx(Text, { children: ui.dim(fitWidth(` ${t().moreTodos(hidden)}`, width)) }) : null] }));
93
134
  }
94
135
  /** A history line for the inline view: the human's line as a bar, a tool group unfolded with Ctrl+O. */
95
136
  function inlineItem(item, expanded, width) {
@@ -133,6 +174,16 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
133
174
  if (height !== historyHeight)
134
175
  setHistoryHeight(height);
135
176
  });
177
+ // The history's height now: `historyHeight` is set by the effect above after the layout,
178
+ // so a key or a click on the first frames would still find the terminal's height.
179
+ function heightNow() {
180
+ if (!historyRef.current)
181
+ return historyHeight;
182
+ const { height } = measureElement(historyRef.current);
183
+ if (height !== historyHeight)
184
+ setHistoryHeight(height);
185
+ return height;
186
+ }
136
187
  const [anchor, setAnchor] = useState(null);
137
188
  // Mirrored in a ref: mouse reports can arrive faster than React re-renders.
138
189
  const [selection, setSelectionState] = useState(null);
@@ -161,12 +212,15 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
161
212
  if (!historyRef.current || end === start)
162
213
  return null;
163
214
  const box = framePosition(historyRef.current);
164
- const firstY = box.y + historyHeight - (end - start); // the rows are bottom-aligned
215
+ const height = heightNow();
216
+ const firstY = box.y + height - (end - start); // the rows are bottom-aligned
165
217
  let offset = y - firstY;
166
- if (offset < 0 || offset >= end - start) {
218
+ // Rows that don't fit are clipped above the box, not clickable.
219
+ const firstShown = Math.max(0, box.y - firstY);
220
+ if (offset < firstShown || offset >= end - start) {
167
221
  if (!clamp)
168
222
  return null;
169
- offset = Math.min(end - start - 1, Math.max(0, offset));
223
+ offset = Math.min(end - start - 1, Math.max(firstShown, offset));
170
224
  }
171
225
  return { row: start + offset, col: Math.max(0, x - box.x) };
172
226
  }
@@ -174,7 +228,8 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
174
228
  for (const event of mouseEvents(text)) {
175
229
  if (event.kind === "wheel-up" || event.kind === "wheel-down") {
176
230
  const steps = event.kind === "wheel-up" ? -WHEEL_ROWS : WHEEL_ROWS;
177
- setAnchor((a) => scrollBy(a, steps, total, historyHeight));
231
+ const height = heightNow();
232
+ setAnchor((a) => scrollBy(a, steps, total, height));
178
233
  }
179
234
  else if (event.kind === "press") {
180
235
  const cell = cellAt(event.x, event.y, false);
@@ -205,15 +260,15 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
205
260
  }
206
261
  }
207
262
  useInput((text, key) => {
208
- const page = Math.max(1, historyHeight - 1);
209
263
  if (isMouseReport(text))
210
264
  handleMouse(text);
211
265
  else if (isFocusReport(text))
212
266
  return;
213
- else if (key.pageUp)
214
- setAnchor((a) => scrollBy(a, -page, total, historyHeight));
215
- else if (key.pageDown)
216
- setAnchor((a) => scrollBy(a, page, total, historyHeight));
267
+ else if (key.pageUp || key.pageDown) {
268
+ const height = heightNow();
269
+ const page = Math.max(1, height - 1);
270
+ setAnchor((a) => scrollBy(a, key.pageUp ? -page : page, total, height));
271
+ }
217
272
  else if (key.ctrl && key.end)
218
273
  setAnchor(null);
219
274
  else if (text && !key.ctrl && !key.meta && !key.escape)
@@ -1,9 +1,11 @@
1
- import type { ApprovalPrompt, InteractionPort } from "../../core/interaction.js";
2
- export type CheckpointKind = "decision" | "manual-intervention";
1
+ import type { ApprovalPrompt, ChoiceSettings, InteractionPort } from "../../core/interaction.js";
2
+ export type CheckpointKind = "decision" | "manual-intervention" | "text" | "choice";
3
3
  export interface Checkpoint {
4
4
  id: number;
5
5
  kind: CheckpointKind;
6
6
  prompt: ApprovalPrompt;
7
+ /** For a choice: the options and whether several can be picked. */
8
+ choice?: ChoiceSettings;
7
9
  /** Settles the checkpoint with the raw answer ("y", "n", "q" for a decision). */
8
10
  answer(value: string): void;
9
11
  }
@@ -1,4 +1,6 @@
1
+ import { parseChoice } from "../../core/hooks/humanInput.js";
1
2
  import * as ui from "../ui.js";
3
+ import { renderMarkdown } from "./markdown.js";
2
4
  import { t } from "../../core/messages/index.js";
3
5
  const outcome = (answer) => ({ y: ui.success(t().approved), n: ui.warn(t().rejected), q: ui.error(t().stopped) })[answer];
4
6
  /**
@@ -25,16 +27,30 @@ export function createInkInteraction(note) {
25
27
  function record(prompt, outcome) {
26
28
  note([ui.heading(`=== ${prompt.title} ===`), ...prompt.lines, outcome].join("\n"));
27
29
  }
28
- function ask(kind, prompt, signal) {
30
+ /** How a settled checkpoint is written to the history. */
31
+ function settledAs(kind, value, choice) {
32
+ if (kind === "decision")
33
+ return outcome(value) ?? value;
34
+ if (kind === "text")
35
+ return value ? ui.success(`✔ ${value}`) : ui.dim(t().noAnswer);
36
+ if (kind === "choice" && choice) {
37
+ const answer = parseChoice(value, choice.options, choice.multiple);
38
+ const words = [...answer.chosen, ...(answer.other ? [answer.other] : [])];
39
+ return words.length ? ui.success(`✔ ${words.join("; ")}`) : ui.dim(t().noAnswer);
40
+ }
41
+ return ui.success(t().done);
42
+ }
43
+ function ask(kind, prompt, signal, choice) {
29
44
  return new Promise((resolve) => {
30
45
  const checkpoint = {
31
46
  id: nextId++,
32
47
  kind,
33
48
  prompt,
49
+ ...(choice ? { choice } : {}),
34
50
  answer(value) {
35
51
  if (!remove(checkpoint))
36
52
  return;
37
- record(prompt, kind === "decision" ? (outcome(value) ?? value) : ui.success(t().done));
53
+ record(prompt.markdown ? { ...prompt, lines: renderMarkdown(prompt.lines.join("\n"), 80) } : prompt, settledAs(kind, value, choice));
38
54
  resolve(value);
39
55
  },
40
56
  };
@@ -50,6 +66,8 @@ export function createInkInteraction(note) {
50
66
  port: {
51
67
  askDecision: (prompt, signal) => ask("decision", prompt, signal),
52
68
  askManualIntervention: (prompt, signal) => ask("manual-intervention", prompt, signal),
69
+ askText: (prompt, signal) => ask("text", prompt, signal),
70
+ askChoice: (prompt, choice, signal) => ask("choice", prompt, signal, choice),
53
71
  notify: (message) => note(message),
54
72
  },
55
73
  subscribe(listener) {
@@ -2,12 +2,16 @@ import type { Mode } from "../../core/agentSpec.js";
2
2
  import { type ConsoleRenderer, type ConsoleRendererOptions } from "../consoleRenderer.js";
3
3
  import { type SessionModel } from "./sessionModel.js";
4
4
  import { type RenderApproval } from "./SessionView.js";
5
- import type { ToolPhrase } from "./toolGroup.js";
5
+ import type { ResultFormatter, ToolDetail, ToolPhrase } from "./toolGroup.js";
6
6
  import { type Theme } from "../theme.js";
7
7
  /** Options of `createProgressView()`. */
8
8
  export interface ProgressViewOptions extends Omit<ConsoleRendererOptions, "output"> {
9
9
  /** How one of the agent's own tools counts in a folded group's summary (see `InkChatOptions.toolPhrase`). */
10
10
  toolPhrase?: (toolName: string) => ToolPhrase | undefined;
11
+ /** How much of the tool calls shows (see `InkChatOptions.toolDetail`); "full" if not given. */
12
+ toolDetail?: ToolDetail;
13
+ /** The result line under a tool call (see `InkChatOptions.formatResult`). */
14
+ formatResult?: ResultFormatter;
11
15
  /** Replaces the default preview in the approval and manual-intervention panels. */
12
16
  renderApproval?: RenderApproval;
13
17
  /** Shown in the status bar. */
@@ -4,7 +4,7 @@ import { type ModeControl } from "../../core/session.js";
4
4
  import { type ChatTuiOptions } from "../chatTui.js";
5
5
  import { type SessionOpener } from "../runs.js";
6
6
  import { type RenderApproval } from "./SessionView.js";
7
- import type { ToolPhrase } from "./toolGroup.js";
7
+ import type { ResultFormatter, ToolDetail, ToolPhrase } from "./toolGroup.js";
8
8
  import { type HeaderInfo } from "./header.js";
9
9
  export type { HeaderInfo } from "./header.js";
10
10
  /** Options of `runChatInk()`: the chat's options plus the Ink-only ones. */
@@ -45,6 +45,21 @@ export interface InkChatOptions extends ChatTuiOptions {
45
45
  * counts as "used {n} tools".
46
46
  */
47
47
  toolPhrase?: (toolName: string) => ToolPhrase | undefined;
48
+ /**
49
+ * How much of the tool calls shows: `"full"` (the default), every call with its result
50
+ * line; `"calls"`, the calls without their results, a failed one with a short line in the
51
+ * kit's language instead of the tool's error; `"summary"`, one line per group ("Read 2
52
+ * files, ran 1 shell command"). Ctrl+O unfolds any of them into the full view. For an
53
+ * audience that doesn't need the tools' raw output. The session log is the same whatever
54
+ * the level.
55
+ */
56
+ toolDetail?: ToolDetail;
57
+ /**
58
+ * The line under a tool call for its result, e.g. a friendly one for an MCP tool whose
59
+ * output is written for the model: a string replaces the kit's (the result's first line),
60
+ * `null` hides it, `undefined` keeps it. Shown wherever results are (`"full"`, or unfolded).
61
+ */
62
+ formatResult?: ResultFormatter;
48
63
  /**
49
64
  * The session's mode control (`buildSessionOptions()` returns it): the status bar shows the
50
65
  * current mode, Shift+Tab switches between the modes it allows ("guided", "interactive"
@@ -210,6 +210,8 @@ export async function runChatInk(options, tuiOptions = {}) {
210
210
  const model = createSessionModel({
211
211
  formatAction: tuiOptions.formatAction,
212
212
  toolPhrase: tuiOptions.toolPhrase,
213
+ toolDetail: tuiOptions.toolDetail,
214
+ formatResult: tuiOptions.formatResult,
213
215
  agentLabel: tuiOptions.agentLabel,
214
216
  onWrite: mirror,
215
217
  width: () => liveWidth(stdout.columns),
@@ -242,12 +244,17 @@ export async function runChatInk(options, tuiOptions = {}) {
242
244
  let run;
243
245
  let knownCommands;
244
246
  let generation = 0;
247
+ let unsubscribeMode;
245
248
  function connect() {
246
249
  const own = ++generation;
247
250
  queue = createInputQueue({ modeControl: modeControl() });
248
251
  run = runQuery(queue.iterable, { ...current.options, promptSuggestions: tuiOptions.promptSuggestions ?? true });
249
252
  const events = run.events[Symbol.asyncIterator]();
250
253
  input.setMode(modeControl()?.mode ?? null);
254
+ // The mode can also change from inside the session (the agent leaving plan mode with
255
+ // present_plan): the status bar follows it.
256
+ unsubscribeMode?.();
257
+ unsubscribeMode = modeControl()?.subscribe?.((mode) => input.setMode(mode));
251
258
  // Every registered command name and alias, built-ins included, for completion and for
252
259
  // catching an unknown "/command" before it reaches the model as plain text (see chatTui.ts).
253
260
  knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
@@ -1,12 +1,13 @@
1
1
  import type { AgentEvent, SessionUsage } from "../../core/runner.js";
2
2
  import { type ConsoleRenderer } from "../consoleRenderer.js";
3
- import { type ToolPhrase } from "./toolGroup.js";
3
+ import { type ResultFormatter, type ToolDetail, type ToolPhrase } from "./toolGroup.js";
4
+ import { type Todo } from "../../core/todos.js";
4
5
  export interface HistoryItem {
5
6
  id: number;
6
7
  text: string;
7
8
  /** What it is: the view draws the human's lines as a bar, and the spacing follows it. */
8
9
  kind?: OutputKind;
9
- /** A folded group of tool calls: the lines it unfolds into with Ctrl+O. */
10
+ /** A group of tool calls: the lines it unfolds into with Ctrl+O (`text` is the folded view). */
10
11
  expanded?: string[];
11
12
  }
12
13
  /**
@@ -30,17 +31,23 @@ export interface SessionSnapshot {
30
31
  activity: string | null;
31
32
  /** Label of the latest action taken inside a subagent in this turn. */
32
33
  subagentActivity: string | null;
33
- /** Tool calls shown one by one with their results (the default); Ctrl+O folds them into a summary line. */
34
+ /** Tool groups unfolded into every call with its result (Ctrl+O toggles it); folded, they show as `toolDetail` says. */
34
35
  expanded: boolean;
35
36
  turns: number;
36
37
  usage: SessionUsage | null;
37
38
  /** How full the context window is (0-100), after the latest turn; null until known. */
38
39
  contextPercent: number | null;
40
+ /** The main agent's task list (TodoWrite), while any task is still to do; null otherwise. */
41
+ todos: Todo[] | null;
39
42
  }
40
43
  export interface SessionModelOptions {
41
44
  formatAction?: (toolName: string, toolInput: unknown) => string;
42
45
  /** How a tool counts in a folded group's summary ("read 2 files"); built-in tools have their own. */
43
46
  toolPhrase?: (toolName: string) => ToolPhrase | undefined;
47
+ /** How much of the tool calls shows until Ctrl+O (see `ToolDetail`); "full" if not given. */
48
+ toolDetail?: ToolDetail;
49
+ /** The result line under a tool call (see `ResultFormatter`). */
50
+ formatResult?: ResultFormatter;
44
51
  agentLabel?: string;
45
52
  /** Called with everything the console renderer writes, e.g. to mirror a session log. */
46
53
  onWrite?: (text: string) => void;
@@ -5,6 +5,7 @@ import { finishedLength, hasTable, renderMarkdown } from "./markdown.js";
5
5
  import { toolGroupExpanded, toolGroupSummary } from "./toolGroup.js";
6
6
  import * as ui from "../ui.js";
7
7
  import { t } from "../../core/messages/index.js";
8
+ import { hasOpenTodos, parseTodos, TODO_TOOL } from "../../core/todos.js";
8
9
  /**
9
10
  * Columns the live area may use: short of the edge, where the Windows console wraps early
10
11
  * (see lineBuffer.ts). Read on every use, so a resized terminal is picked up.
@@ -29,6 +30,7 @@ const isBlank = (line) => stripAnsi(line).trim() === "";
29
30
  */
30
31
  export function createSessionModel(options = {}) {
31
32
  const formatAction = options.formatAction ?? createFriendlyToolLabel();
33
+ const toolDetail = options.toolDetail ?? "full";
32
34
  const width = () => Math.max(20, options.width?.() ?? 80);
33
35
  const listeners = new Set();
34
36
  let nextId = 0;
@@ -40,10 +42,11 @@ export function createSessionModel(options = {}) {
40
42
  turnStartedAt: null,
41
43
  activity: null,
42
44
  subagentActivity: null,
43
- expanded: true,
45
+ expanded: toolDetail === "full",
44
46
  turns: 0,
45
47
  usage: null,
46
48
  contextPercent: null,
49
+ todos: null,
47
50
  };
48
51
  function update(changes) {
49
52
  snapshot = { ...snapshot, ...changes };
@@ -146,7 +149,15 @@ export function createSessionModel(options = {}) {
146
149
  return;
147
150
  const calls = group;
148
151
  group = null;
149
- push([groupLine(calls)], "action", { expanded: toolGroupExpanded(calls, width()) });
152
+ push([folded(calls).join("\n")], "action", { expanded: unfolded(calls) });
153
+ }
154
+ /** A group with every call and its result: "full", or any level unfolded with Ctrl+O. */
155
+ function unfolded(calls) {
156
+ return toolGroupExpanded(calls, width(), { formatResult: options.formatResult });
157
+ }
158
+ /** A group as its level shows it folded: the calls without results, or one summary line. */
159
+ function folded(calls) {
160
+ return toolDetail === "calls" ? toolGroupExpanded(calls, width(), { results: false }) : [groupLine(calls)];
150
161
  }
151
162
  function groupLine(calls) {
152
163
  return INDENT + ui.dim(fitWidth(toolGroupSummary(calls, options.toolPhrase), width() - INDENT.length));
@@ -161,7 +172,7 @@ export function createSessionModel(options = {}) {
161
172
  let rows = liveRows;
162
173
  let kind = liveRows.length > 0 ? "agent" : null;
163
174
  if (group) {
164
- rows = snapshot.expanded ? toolGroupExpanded(group, width()) : [groupLine(group)];
175
+ rows = snapshot.expanded ? unfolded(group) : folded(group);
165
176
  kind = "action";
166
177
  }
167
178
  const live = rows.join("\n");
@@ -181,6 +192,15 @@ export function createSessionModel(options = {}) {
181
192
  return;
182
193
  }
183
194
  case "action": {
195
+ // The task list isn't a tool call to show: it's drawn above the prompt, and the task
196
+ // in progress becomes the spinner's label.
197
+ const todos = event.toolName === TODO_TOOL ? parseTodos(event.input) : null;
198
+ if (todos) {
199
+ const current = todos.find((todo) => todo.status === "in_progress");
200
+ snapshot = { ...snapshot, todos: hasOpenTodos(todos) ? todos : null, activity: current?.activeForm ?? snapshot.activity };
201
+ refreshLive();
202
+ return;
203
+ }
184
204
  flushSegment(true);
185
205
  group ??= [];
186
206
  group.push({ id: event.toolUseId, label: formatAction(event.toolName, event.input), toolName: event.toolName, result: null });
@@ -189,6 +209,8 @@ export function createSessionModel(options = {}) {
189
209
  return;
190
210
  }
191
211
  case "tool-result": {
212
+ if (event.toolName === TODO_TOOL)
213
+ return;
192
214
  const call = group?.find((c) => c.id === event.toolUseId);
193
215
  if (call)
194
216
  call.result = { isError: event.isError, text: event.text };
@@ -273,7 +295,7 @@ export function createSessionModel(options = {}) {
273
295
  lastKind = null;
274
296
  gapPending = false;
275
297
  reply = "";
276
- snapshot = { ...snapshot, items: [], live: "", liveGap: false, turns: 0, usage: null, contextPercent: null };
298
+ snapshot = { ...snapshot, items: [], live: "", liveGap: false, turns: 0, usage: null, contextPercent: null, todos: null };
277
299
  refreshLive();
278
300
  },
279
301
  replay(messages, userLine) {
@@ -13,6 +13,27 @@ export interface ToolCall {
13
13
  }
14
14
  /** How a tool counts in a group's summary: the phrase for one call and for several, "{n}" standing for the count. */
15
15
  export type { ToolPhrase };
16
+ /**
17
+ * How much of its tool calls a chat shows: `"full"`, every call with its result line;
18
+ * `"calls"`, the calls without their results (a failed call says so in one short line);
19
+ * `"summary"`, one line per group. Ctrl+O unfolds any of them into the full view.
20
+ */
21
+ export type ToolDetail = "full" | "calls" | "summary";
22
+ /**
23
+ * The line shown under a tool call for its result: a string replaces the kit's (plain text,
24
+ * drawn in the result's color, or the error color if it failed), `null` hides it, and
25
+ * `undefined` keeps the kit's (the result's first line).
26
+ */
27
+ export type ResultFormatter = (toolName: string, result: {
28
+ isError: boolean;
29
+ text: string;
30
+ }) => string | null | undefined;
31
+ /** How `toolGroupExpanded()` draws the results. */
32
+ export interface ResultOptions {
33
+ /** Show the result lines (the default); without them a failed call shows a short failure line. */
34
+ results?: boolean;
35
+ formatResult?: ResultFormatter;
36
+ }
16
37
  /**
17
38
  * A folded group's one line, as in the Claude Code CLI: "Read 2 files, ran 1 shell
18
39
  * command". Calls to the same kind of tool are counted together, in the order they first
@@ -32,6 +53,6 @@ export declare function resultSummary(result: {
32
53
  /**
33
54
  * The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
34
55
  * latest few, dim) and the result in one line (a subagent's answer with its markdown
35
- * rendered), or "…" while it runs.
56
+ * rendered), or "…" while it runs. Without `options.results`, only a failure's short line.
36
57
  */
37
- export declare function toolGroupExpanded(calls: readonly ToolCall[], width: number): string[];
58
+ export declare function toolGroupExpanded(calls: readonly ToolCall[], width: number, options?: ResultOptions): string[];
@@ -43,20 +43,33 @@ export function resultSummary(result, markdown = false) {
43
43
  // would cancel a dim around it.
44
44
  return ui.toolResult(markdown ? stripAnsi(renderMarkdown(first, 1000)[0] ?? first) : first) + more;
45
45
  }
46
+ /** The line under a call for its result, or null for none. */
47
+ function resultLine(call, { results = true, formatResult }) {
48
+ if (!call.result)
49
+ return results ? ui.dim("…") : null;
50
+ if (!results)
51
+ return call.result.isError ? ui.error(t().toolFailed) : null;
52
+ const custom = formatResult?.(call.toolName, call.result);
53
+ if (custom === null)
54
+ return null;
55
+ if (custom !== undefined)
56
+ return call.result.isError ? ui.error(custom) : ui.toolResult(custom);
57
+ return resultSummary(call.result, SUBAGENT_TOOLS.has(call.toolName));
58
+ }
46
59
  /**
47
60
  * The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
48
61
  * latest few, dim) and the result in one line (a subagent's answer with its markdown
49
- * rendered), or "…" while it runs.
62
+ * rendered), or "…" while it runs. Without `options.results`, only a failure's short line.
50
63
  */
51
- export function toolGroupExpanded(calls, width) {
64
+ export function toolGroupExpanded(calls, width, options = {}) {
52
65
  return calls.flatMap((call) => {
53
66
  const children = call.children ?? [];
54
67
  const shown = children.slice(-MAX_CHILDREN);
55
- const result = call.result ? fitWidth(resultSummary(call.result, SUBAGENT_TOOLS.has(call.toolName)), width - 5) : ui.dim("…");
68
+ const result = resultLine(call, options);
56
69
  const under = [
57
70
  ...(children.length > shown.length ? [ui.dim(t().earlierCalls(children.length - shown.length))] : []),
58
71
  ...shown.map((label) => fitWidth(ui.dim(`· ${label}`), width - 5)),
59
- result,
72
+ ...(result === null ? [] : [fitWidth(result, width - 5)]),
60
73
  ];
61
74
  return [
62
75
  `${ui.toolBullet("●")} ${fitWidth(ui.toolLabel(call.label), width - 2)}`,
@@ -80,6 +80,8 @@ async function askOnTerminal(prompt, signal) {
80
80
  export const terminalInteractionPort = {
81
81
  askDecision: askOnTerminal,
82
82
  askManualIntervention: askOnTerminal,
83
+ askText: askOnTerminal,
84
+ askChoice: (prompt, choice, signal) => askOnTerminal({ ...prompt, lines: [...prompt.lines, ...choice.options.map((option, i) => `${i + 1}. ${option}`)], question: t().choiceQuestion(choice.multiple) }, signal),
83
85
  notify(message) {
84
86
  if (stdin.isTTY)
85
87
  console.log(message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falkenslab/agent-kit",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Library for building an agent on top of the Claude Agent SDK",
5
5
  "author": "Francisco Vargas Ruiz",
6
6
  "license": "MIT",
@@ -62,11 +62,17 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@eslint/js": "^10.0.1",
65
+ "@mozilla/readability": "^0.6.0",
65
66
  "@types/node": "^24.0.0",
67
+ "@types/turndown": "^5.0.6",
66
68
  "eslint": "^10.9.1",
69
+ "fflate": "^0.8.3",
67
70
  "globals": "^17.11.0",
68
71
  "ink-testing-library": "^4.0.0",
72
+ "linkedom": "^0.18.13",
73
+ "mammoth": "^1.13.0",
69
74
  "tsx": "^4.19.0",
75
+ "turndown": "^7.2.4",
70
76
  "typescript": "^5.7.0",
71
77
  "typescript-eslint": "^8.68.0"
72
78
  },
@@ -75,5 +81,29 @@
75
81
  },
76
82
  "publishConfig": {
77
83
  "access": "public"
84
+ },
85
+ "peerDependencies": {
86
+ "@mozilla/readability": ">=0.5.0",
87
+ "linkedom": ">=0.16.0",
88
+ "turndown": ">=7.0.0",
89
+ "mammoth": ">=1.6.0",
90
+ "fflate": ">=0.8.0"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@mozilla/readability": {
94
+ "optional": true
95
+ },
96
+ "linkedom": {
97
+ "optional": true
98
+ },
99
+ "turndown": {
100
+ "optional": true
101
+ },
102
+ "mammoth": {
103
+ "optional": true
104
+ },
105
+ "fflate": {
106
+ "optional": true
107
+ }
78
108
  }
79
109
  }