tinker-agent 1.8.0 → 1.10.1

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 (54) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +65 -6
  3. package/package.json +1 -1
  4. package/src/agent/loop.ts +13 -0
  5. package/src/agent/runtime-session.ts +165 -0
  6. package/src/agent/session-ledger.ts +20 -3
  7. package/src/cli/config.ts +11 -2
  8. package/src/cli/model-profiles.ts +58 -0
  9. package/src/cli/public-config-contract.ts +73 -7
  10. package/src/cli/run-runner.ts +4 -1
  11. package/src/cli/runner-dependencies.ts +35 -10
  12. package/src/cli/tui-memory.ts +4 -0
  13. package/src/cli/tui-runner.tsx +8 -1
  14. package/src/context/context-automation-policy.ts +22 -21
  15. package/src/context/context-manager.ts +91 -15
  16. package/src/context/context-policy.ts +0 -2
  17. package/src/context/context-swap-renderer.ts +1 -1
  18. package/src/context/prefix-retirement-planner.ts +58 -8
  19. package/src/context/recall-retirement-contract.ts +5 -4
  20. package/src/context/swap-planner.ts +33 -27
  21. package/src/events/stdout-event-printer.ts +17 -0
  22. package/src/model/fake-model-client.ts +26 -16
  23. package/src/model/model-api.ts +12 -0
  24. package/src/model/model-client.ts +9 -1
  25. package/src/model/moonshot-input-token-estimator.ts +5 -1
  26. package/src/model/openai-chat-mapping.ts +2 -24
  27. package/src/model/openai-chat-model-client.ts +18 -294
  28. package/src/model/openai-image-mapping.ts +20 -0
  29. package/src/model/openai-model-utils.ts +304 -0
  30. package/src/model/openai-responses-mapping.ts +532 -0
  31. package/src/model/openai-responses-model-client.ts +295 -0
  32. package/src/model/openai-responses-stream.ts +96 -0
  33. package/src/model/openai-responses-token-estimator.ts +155 -0
  34. package/src/model/reasoning-effort.ts +60 -0
  35. package/src/observation/observation-builder.ts +7 -0
  36. package/src/session/session-catalog.ts +2 -2
  37. package/src/session/session-history-reader.ts +6 -1
  38. package/src/session/session-schema.ts +268 -4
  39. package/src/session/session-store.ts +106 -26
  40. package/src/skills/skill-context.ts +2 -2
  41. package/src/tools/bounded-output-preview.ts +276 -0
  42. package/src/tools/recall.ts +67 -36
  43. package/src/tools/registry.ts +9 -2
  44. package/src/tools/task-output-snapshot.ts +6 -22
  45. package/src/tools/task-output.ts +23 -27
  46. package/src/tools/types.ts +19 -0
  47. package/src/tools/update-plan.ts +166 -0
  48. package/src/tui/app.tsx +82 -5
  49. package/src/tui/components/plan-view.tsx +43 -0
  50. package/src/tui/components/prompt-input.tsx +9 -1
  51. package/src/tui/components/timeline.tsx +7 -0
  52. package/src/tui/event-store.ts +28 -2
  53. package/src/tui/slash-commands.ts +20 -0
  54. package/src/tui/tui-session-controller.ts +7 -0
@@ -144,6 +144,24 @@ export type TaskListRawResult = {
144
144
  error?: string;
145
145
  };
146
146
 
147
+ export type PlanStepStatus = "pending" | "in_progress" | "completed";
148
+
149
+ export type PlanStep = {
150
+ step: string;
151
+ status: PlanStepStatus;
152
+ };
153
+
154
+ export type UpdatePlanRawResult =
155
+ | {
156
+ ok: true;
157
+ explanation?: string;
158
+ plan: PlanStep[];
159
+ }
160
+ | {
161
+ ok: false;
162
+ error: string;
163
+ };
164
+
147
165
  export type TaskOutputRawResult = {
148
166
  ok: boolean;
149
167
  taskId: string;
@@ -355,6 +373,7 @@ export type ToolRawResultByKind = {
355
373
  glob: GlobRawResult;
356
374
  grep: GrepRawResult;
357
375
  bash: BashRawResult;
376
+ update_plan: UpdatePlanRawResult;
358
377
  task_list: TaskListRawResult;
359
378
  task_output: TaskOutputRawResult;
360
379
  task_input: TaskInputRawResult;
@@ -0,0 +1,166 @@
1
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
2
+ import {
3
+ defineToolExecutor,
4
+ type PlanStep,
5
+ type ToolExecutionContext,
6
+ type ToolExecutor,
7
+ type UpdatePlanRawResult,
8
+ } from "./types";
9
+
10
+ const MAX_PLAN_ITEMS = 12;
11
+ const MAX_STEP_LENGTH = 200;
12
+ const MAX_EXPLANATION_LENGTH = 500;
13
+ const STATUSES = new Set(["pending", "in_progress", "completed"]);
14
+
15
+ export function createUpdatePlanToolExecutor(): ToolExecutor {
16
+ return defineToolExecutor("update_plan", {
17
+ definition: {
18
+ name: "UpdatePlan",
19
+ description:
20
+ "Replace the current task plan with a complete ordered list of steps and their progress. Use an optional explanation when revising the approach. At most one step may be in_progress.",
21
+ parameters: {
22
+ type: "object",
23
+ additionalProperties: false,
24
+ properties: {
25
+ explanation: {
26
+ type: "string",
27
+ maxLength: MAX_EXPLANATION_LENGTH,
28
+ description: "Optional explanation for this plan update.",
29
+ },
30
+ plan: {
31
+ type: "array",
32
+ maxItems: MAX_PLAN_ITEMS,
33
+ description: "The complete ordered task plan.",
34
+ items: {
35
+ type: "object",
36
+ additionalProperties: false,
37
+ properties: {
38
+ step: {
39
+ type: "string",
40
+ minLength: 1,
41
+ maxLength: MAX_STEP_LENGTH,
42
+ description: "Task step text.",
43
+ },
44
+ status: {
45
+ type: "string",
46
+ enum: ["pending", "in_progress", "completed"],
47
+ description: "Step status.",
48
+ },
49
+ },
50
+ required: ["step", "status"],
51
+ },
52
+ },
53
+ },
54
+ required: ["plan"],
55
+ },
56
+ },
57
+ async execute(
58
+ args,
59
+ _call,
60
+ context: ToolExecutionContext,
61
+ ): Promise<UpdatePlanRawResult> {
62
+ throwIfTurnCancelled(context.signal);
63
+ const parsed = parseUpdatePlanArgs(args);
64
+ if (!parsed.ok) {
65
+ return parsed;
66
+ }
67
+ throwIfTurnCancelled(context.signal);
68
+ return {
69
+ ok: true,
70
+ ...(parsed.explanation === undefined
71
+ ? {}
72
+ : { explanation: parsed.explanation }),
73
+ plan: parsed.plan,
74
+ };
75
+ },
76
+ });
77
+ }
78
+
79
+ type ParsedUpdatePlanArgs =
80
+ | { ok: true; explanation?: string; plan: PlanStep[] }
81
+ | { ok: false; error: string };
82
+
83
+ function parseUpdatePlanArgs(args: unknown): ParsedUpdatePlanArgs {
84
+ if (!isRecord(args)) {
85
+ return failure("UpdatePlan arguments must be an object.");
86
+ }
87
+ const unexpected = Object.keys(args).find(
88
+ (key) => key !== "explanation" && key !== "plan",
89
+ );
90
+ if (unexpected !== undefined) {
91
+ return failure(`UpdatePlan received unexpected argument: ${unexpected}.`);
92
+ }
93
+ if (!Array.isArray(args.plan)) {
94
+ return failure("UpdatePlan plan must be an array.");
95
+ }
96
+ if (args.plan.length > MAX_PLAN_ITEMS) {
97
+ return failure(`UpdatePlan accepts at most ${MAX_PLAN_ITEMS} steps.`);
98
+ }
99
+
100
+ let explanation: string | undefined;
101
+ if (args.explanation !== undefined) {
102
+ if (typeof args.explanation !== "string") {
103
+ return failure("UpdatePlan explanation must be a string.");
104
+ }
105
+ explanation = args.explanation.trim();
106
+ if (explanation.length > MAX_EXPLANATION_LENGTH) {
107
+ return failure(
108
+ `UpdatePlan explanation must be at most ${MAX_EXPLANATION_LENGTH} characters.`,
109
+ );
110
+ }
111
+ if (explanation === "") {
112
+ explanation = undefined;
113
+ }
114
+ }
115
+
116
+ const plan: PlanStep[] = [];
117
+ let inProgressCount = 0;
118
+ for (const [index, item] of args.plan.entries()) {
119
+ if (!isRecord(item)) {
120
+ return failure(`UpdatePlan plan[${index}] must be an object.`);
121
+ }
122
+ const unexpectedItemKey = Object.keys(item).find(
123
+ (key) => key !== "step" && key !== "status",
124
+ );
125
+ if (unexpectedItemKey !== undefined) {
126
+ return failure(
127
+ `UpdatePlan plan[${index}] received unexpected field: ${unexpectedItemKey}.`,
128
+ );
129
+ }
130
+ if (typeof item.step !== "string" || item.step.trim() === "") {
131
+ return failure(`UpdatePlan plan[${index}].step must be a non-empty string.`);
132
+ }
133
+ const step = item.step.trim();
134
+ if (step.length > MAX_STEP_LENGTH) {
135
+ return failure(
136
+ `UpdatePlan plan[${index}].step must be at most ${MAX_STEP_LENGTH} characters.`,
137
+ );
138
+ }
139
+ if (typeof item.status !== "string" || !STATUSES.has(item.status)) {
140
+ return failure(
141
+ `UpdatePlan plan[${index}].status must be pending, in_progress, or completed.`,
142
+ );
143
+ }
144
+ if (item.status === "in_progress") {
145
+ inProgressCount += 1;
146
+ }
147
+ plan.push({ step, status: item.status as PlanStep["status"] });
148
+ }
149
+ if (inProgressCount > 1) {
150
+ return failure("UpdatePlan allows at most one in_progress step.");
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ ...(explanation === undefined ? {} : { explanation }),
156
+ plan,
157
+ };
158
+ }
159
+
160
+ function failure(error: string): ParsedUpdatePlanArgs {
161
+ return { ok: false, error };
162
+ }
163
+
164
+ function isRecord(value: unknown): value is Record<string, unknown> {
165
+ return typeof value === "object" && value !== null && !Array.isArray(value);
166
+ }
package/src/tui/app.tsx CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from "../context/context-manager";
18
18
  import { ContextBudgetExceededError } from "../model/model-request-preflight";
19
19
  import { ModelRequestMediaAggregateError } from "../model/model-client";
20
+ import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
20
21
  import type { SessionId } from "../ids/runtime-id";
21
22
  import { readLastAssistantResponse } from "../session/session-last-response-reader";
22
23
  import type { PromptHistory } from "./prompt-history";
@@ -180,15 +181,43 @@ export function App(props: AppProps) {
180
181
  state.activeTurn === undefined &&
181
182
  props.profiles !== undefined &&
182
183
  props.profiles.profiles.size > 1;
184
+ const reasoningEffort = binding.reasoningEffort?.();
185
+ const hasReasoningEffort = reasoningEffort !== undefined;
183
186
  const activeResumePicker =
184
187
  resumePicker?.ownerSessionId === binding.sessionId ? resumePicker : undefined;
185
188
 
186
- const builtInCommands = canSwitchModel
187
- ? SLASH_COMMANDS
188
- : SLASH_COMMANDS.filter((command) => command.name !== "model");
189
+ const builtInCommands = SLASH_COMMANDS.filter(
190
+ (command) =>
191
+ (command.name !== "model" || canSwitchModel) &&
192
+ (command.name !== "reasoning" || hasReasoningEffort),
193
+ );
189
194
  const availableCommands = [...builtInCommands, ...(props.projectSlashCommands ?? [])];
190
195
 
191
196
  const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
197
+ const cycleReasoningEffort = () => {
198
+ const current = binding.reasoningEffort?.();
199
+ if (current === undefined) {
200
+ return;
201
+ }
202
+ const currentIndex = current.supportedEfforts.indexOf(current.effort);
203
+ const nextIndex =
204
+ currentIndex < 0 ? 0 : (currentIndex + 1) % current.supportedEfforts.length;
205
+ const nextEffort = current.supportedEfforts[nextIndex];
206
+ if (nextEffort === undefined) {
207
+ return;
208
+ }
209
+ try {
210
+ const updated = binding.setReasoningEffort?.(nextEffort);
211
+ if (updated === undefined) {
212
+ throw new Error("Reasoning effort control is unavailable.");
213
+ }
214
+ setNotice(
215
+ `Reasoning effort cycled from ${JSON.stringify(current.effort)} to ${JSON.stringify(updated.effort)} (Ctrl+R).`,
216
+ );
217
+ } catch (error) {
218
+ setNotice(errorMessage(error));
219
+ }
220
+ };
192
221
 
193
222
  useEffect(() => {
194
223
  if (readGitBranch === undefined) {
@@ -660,6 +689,42 @@ export function App(props: AppProps) {
660
689
  exit();
661
690
  return true;
662
691
  }
692
+ if (
693
+ command.type === "reasoning_status" ||
694
+ command.type === "reasoning_reset" ||
695
+ command.type === "reasoning_set"
696
+ ) {
697
+ const current = binding.reasoningEffort?.();
698
+ if (current === undefined) {
699
+ setNotice("Current model profile does not configure reasoning effort.");
700
+ return false;
701
+ }
702
+ try {
703
+ if (command.type === "reasoning_status") {
704
+ setNotice(formatReasoningEffortStatus(current));
705
+ } else if (command.type === "reasoning_reset") {
706
+ const reset = binding.resetReasoningEffort?.();
707
+ if (reset === undefined) {
708
+ throw new Error("Reasoning effort control is unavailable.");
709
+ }
710
+ setNotice(
711
+ `Reasoning effort reset to profile default ${JSON.stringify(reset.defaultEffort)}.`,
712
+ );
713
+ } else {
714
+ const updated = binding.setReasoningEffort?.(command.effort);
715
+ if (updated === undefined) {
716
+ throw new Error("Reasoning effort control is unavailable.");
717
+ }
718
+ setNotice(
719
+ `Reasoning effort set to ${JSON.stringify(updated.effort)} for this session runtime (profile default: ${JSON.stringify(updated.defaultEffort)}).`,
720
+ );
721
+ }
722
+ } catch (error) {
723
+ setNotice(errorMessage(error));
724
+ return false;
725
+ }
726
+ return true;
727
+ }
663
728
  if (command.type === "model" || command.type === "model_switch") {
664
729
  if (!canSwitchModel) {
665
730
  setNotice(
@@ -821,6 +886,7 @@ export function App(props: AppProps) {
821
886
  ) : (
822
887
  <PromptInput
823
888
  modelName={binding.modelName}
889
+ reasoningEffort={reasoningEffort?.effort}
824
890
  workspaceRoot={binding.workspaceRoot}
825
891
  gitBranch={gitBranch}
826
892
  contextUsage={state.contextUsage}
@@ -835,6 +901,9 @@ export function App(props: AppProps) {
835
901
  fileLister={props.fileLister}
836
902
  importImage={binding.importImage}
837
903
  verifyImageAssets={binding.verifyImageAssets}
904
+ onCycleReasoningEffort={
905
+ hasReasoningEffort ? cycleReasoningEffort : undefined
906
+ }
838
907
  onSubmit={onSubmit}
839
908
  onMaintenance={onMaintenance}
840
909
  placeholder='Enter a coding request, or "/" for commands'
@@ -850,6 +919,14 @@ export function App(props: AppProps) {
850
919
  );
851
920
  }
852
921
 
922
+ function formatReasoningEffortStatus(snapshot: ReasoningEffortSnapshot): string {
923
+ const source =
924
+ snapshot.source === "profile_default"
925
+ ? "profile default"
926
+ : `session override; profile default: ${snapshot.defaultEffort}`;
927
+ return `Reasoning effort: ${snapshot.effort} (${source}). Available: ${snapshot.supportedEfforts.join(", ")}.`;
928
+ }
929
+
853
930
  function errorMessage(error: unknown): string {
854
931
  return error instanceof Error ? error.message : String(error);
855
932
  }
@@ -947,7 +1024,7 @@ export function formatContextRetirementNotice(result: ContextRetirementResult):
947
1024
  const before = result.guardedTokensBefore.toLocaleString("en-US");
948
1025
  const after = result.guardedTokensAfter.toLocaleString("en-US");
949
1026
  if (result.outcome === "retirement_floor") {
950
- return `Context prefix retired: revision ${result.previousRevisionNumber} -> ${result.revisionNumber}, ${result.retiredTurnCount} turns removed from the active request, ${before} -> ${after} estimated tokens; target ${result.targetTokens.toLocaleString("en-US")} was not reached. Run /compact first when retained tool output is still eligible. Older history remains available through Recall.`;
1027
+ return `Context prefix retired: revision ${result.previousRevisionNumber} -> ${result.revisionNumber}, ${result.retiredTurnCount} turns removed from the active request, ${before} -> ${after} estimated tokens; target ${result.targetTokens.toLocaleString("en-US")} was not reached. Run /compact first when retained tool output is still eligible. Older history remains available through RecallSearch and RecallGet.`;
951
1028
  }
952
1029
  const reduction =
953
1030
  result.guardedTokensBefore === 0
@@ -957,7 +1034,7 @@ export function formatContextRetirementNotice(result: ContextRetirementResult):
957
1034
  result.guardedTokensBefore) *
958
1035
  100
959
1036
  ).toFixed(1);
960
- return `Context prefix retired: revision ${result.previousRevisionNumber} -> ${result.revisionNumber}, ${result.retiredTurnCount} turns removed from the active request, ${before} -> ${after} estimated tokens (-${reduction}%). Older history remains available through Recall.`;
1037
+ return `Context prefix retired: revision ${result.previousRevisionNumber} -> ${result.revisionNumber}, ${result.retiredTurnCount} turns removed from the active request, ${before} -> ${after} estimated tokens (-${reduction}%). Older history remains available through RecallSearch and RecallGet.`;
961
1038
  }
962
1039
 
963
1040
  export function formatContextRetirementFailureNotice(error: unknown): string {
@@ -0,0 +1,43 @@
1
+ import { Box, Text } from "ink";
2
+ import type { TimelineItem } from "../event-store";
3
+
4
+ export function PlanView(props: { plan: NonNullable<TimelineItem["plan"]> }) {
5
+ return (
6
+ <Box flexDirection="column" marginLeft={2}>
7
+ {props.plan.explanation === undefined ? null : (
8
+ <Text dimColor italic>
9
+ {props.plan.explanation}
10
+ </Text>
11
+ )}
12
+ {props.plan.steps.length === 0 ? (
13
+ <Text dimColor italic>
14
+ (no steps)
15
+ </Text>
16
+ ) : (
17
+ props.plan.steps.map((step, index) => (
18
+ <PlanStepRow key={`${index}:${step.step}`} step={step} />
19
+ ))
20
+ )}
21
+ </Box>
22
+ );
23
+ }
24
+
25
+ function PlanStepRow(props: {
26
+ step: NonNullable<TimelineItem["plan"]>["steps"][number];
27
+ }) {
28
+ if (props.step.status === "completed") {
29
+ return (
30
+ <Text dimColor strikethrough>
31
+ ✓ {props.step.step}
32
+ </Text>
33
+ );
34
+ }
35
+ if (props.step.status === "in_progress") {
36
+ return (
37
+ <Text color="cyan" bold>
38
+ → {props.step.step}
39
+ </Text>
40
+ );
41
+ }
42
+ return <Text dimColor>• {props.step.step}</Text>;
43
+ }
@@ -60,6 +60,7 @@ export type PromptSubmissionOutcome =
60
60
 
61
61
  export type PromptInputProps = {
62
62
  modelName: string;
63
+ reasoningEffort?: string;
63
64
  workspaceRoot: string;
64
65
  gitBranch?: string;
65
66
  contextUsage?: ContextUsageSnapshot;
@@ -81,6 +82,7 @@ export type PromptInputProps = {
81
82
  assets: readonly ImageAssetRef[],
82
83
  signal: AbortSignal,
83
84
  ) => Promise<void>;
85
+ onCycleReasoningEffort?: () => void;
84
86
  onSubmit: (
85
87
  submission: PromptSubmission,
86
88
  signal: AbortSignal,
@@ -605,6 +607,10 @@ export function PromptInput(props: PromptInputProps) {
605
607
  }
606
608
  return;
607
609
  }
610
+ if (key.ctrl && input === "r") {
611
+ props.onCycleReasoningEffort?.();
612
+ return;
613
+ }
608
614
  const selectedFile = fileMatches[selectedIndex];
609
615
  const selectedCommand = suggestions[selectedIndex];
610
616
  if (key.return) {
@@ -736,7 +742,9 @@ export function PromptInput(props: PromptInputProps) {
736
742
  {showSuggestions ? null : (
737
743
  <Box>
738
744
  <Text dimColor>
739
- {props.modelName} · {formatWorkspacePath(props.workspaceRoot)}
745
+ {props.modelName}
746
+ {props.reasoningEffort === undefined ? null : ` ${props.reasoningEffort}`} ·{" "}
747
+ {formatWorkspacePath(props.workspaceRoot)}
740
748
  {props.gitBranch === undefined ? null : ` · ${props.gitBranch}`}
741
749
  {state.phase.kind === "idle" ? null : ` · ${phaseLabel(state.phase)}`}
742
750
  </Text>
@@ -5,6 +5,7 @@ import type { AssistantStreamSectionItem } from "../tui-projection-store";
5
5
  import { AssistantMarkdown } from "./assistant-markdown";
6
6
  import { BashResultView } from "./bash-result-view";
7
7
  import { DiffView } from "./diff-view";
8
+ import { PlanView } from "./plan-view";
8
9
 
9
10
  export type TimelineProps = {
10
11
  items: readonly TimelineItem[];
@@ -42,6 +43,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
42
43
  )}
43
44
  {renderItemBash(item)}
44
45
  {renderItemDiff(item)}
46
+ {renderItemPlan(item)}
45
47
  </Fragment>
46
48
  );
47
49
  }
@@ -51,6 +53,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
51
53
  <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
52
54
  {renderItemBash(item)}
53
55
  {renderItemDiff(item)}
56
+ {renderItemPlan(item)}
54
57
  </Fragment>
55
58
  );
56
59
  }
@@ -105,6 +108,10 @@ function renderItemBash(item: TimelineItem) {
105
108
  return <BashResultView detail={item.bash} />;
106
109
  }
107
110
 
111
+ function renderItemPlan(item: TimelineItem) {
112
+ return item.plan === undefined ? null : <PlanView plan={item.plan} />;
113
+ }
114
+
108
115
  function formatTimelineItem(item: TimelineItem): string {
109
116
  if (item.status === "text") {
110
117
  return item.text;
@@ -11,7 +11,7 @@ import type {
11
11
  } from "../model/model-context-profile";
12
12
  import type { ShellTaskSnapshot, ShellTaskStatus } from "../tools/bash-task";
13
13
  import { countPatchChanges } from "../tools/file-diff";
14
- import type { DiffHunk, ToolRawResult } from "../tools/types";
14
+ import type { DiffHunk, PlanStep, ToolRawResult } from "../tools/types";
15
15
  import {
16
16
  defaultTuiProjectionPolicy,
17
17
  type TuiProjectionPolicy,
@@ -33,6 +33,10 @@ export type TimelineItem = {
33
33
  diffTruncated?: boolean;
34
34
  bash?: BashDisplayDetail;
35
35
  userPrompt?: UserPromptProjection;
36
+ plan?: {
37
+ explanation?: string;
38
+ steps: readonly PlanStep[];
39
+ };
36
40
  };
37
41
 
38
42
  export type TuiTurnProjection = {
@@ -747,11 +751,12 @@ export function toolCallStartedProjection(input: {
747
751
  export function toolRawResultProjection(
748
752
  input: { name: string; args: unknown },
749
753
  raw: ToolRawResult,
750
- ): Pick<TimelineItem, "text" | "bash" | "diff" | "diffTruncated"> {
754
+ ): Pick<TimelineItem, "text" | "bash" | "diff" | "diffTruncated" | "plan"> {
751
755
  return {
752
756
  text: toolRawResultSummary(input.name, input.args, raw),
753
757
  ...toolRawResultDiff(raw),
754
758
  ...toolRawResultBashDetail(raw),
759
+ ...toolRawResultPlanDetail(raw),
755
760
  };
756
761
  }
757
762
 
@@ -888,6 +893,13 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
888
893
  : `${base} -> running ${raw.outputFilePath}`;
889
894
  }
890
895
  return raw.exitCode === undefined ? base : `${base} -> exit ${raw.exitCode}`;
896
+ case "update_plan": {
897
+ if (!raw.ok) {
898
+ return base;
899
+ }
900
+ const completed = raw.plan.filter((step) => step.status === "completed").length;
901
+ return `${base} -> ${completed}/${raw.plan.length} completed`;
902
+ }
891
903
  case "mcp":
892
904
  case "generic":
893
905
  return base;
@@ -929,6 +941,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
929
941
  case "delete":
930
942
  case "glob":
931
943
  case "grep":
944
+ case "update_plan":
932
945
  case "task_list":
933
946
  case "task_stop":
934
947
  case "web_search":
@@ -964,6 +977,7 @@ function toolRawResultDiff(
964
977
  case "glob":
965
978
  case "grep":
966
979
  case "bash":
980
+ case "update_plan":
967
981
  case "task_list":
968
982
  case "task_output":
969
983
  case "task_input":
@@ -981,6 +995,18 @@ function toolRawResultDiff(
981
995
  }
982
996
  }
983
997
 
998
+ function toolRawResultPlanDetail(raw: ToolRawResult): Pick<TimelineItem, "plan"> {
999
+ if (raw.kind !== "update_plan" || !raw.ok) {
1000
+ return {};
1001
+ }
1002
+ return {
1003
+ plan: {
1004
+ ...(raw.explanation === undefined ? {} : { explanation: raw.explanation }),
1005
+ steps: raw.plan.map((step) => ({ ...step })),
1006
+ },
1007
+ };
1008
+ }
1009
+
984
1010
  function upsertBackgroundTask(
985
1011
  tasks: ShellTaskSnapshot[],
986
1012
  task: ShellTaskSnapshot,
@@ -65,6 +65,11 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
65
65
  usage: "/model [profile-name]",
66
66
  description: "Switch model profile (new session)",
67
67
  },
68
+ {
69
+ name: "reasoning",
70
+ usage: "/reasoning [effort|reset]",
71
+ description: "Show or change reasoning effort for this session runtime",
72
+ },
68
73
  {
69
74
  name: "resume",
70
75
  usage: "/resume [session-id]",
@@ -95,6 +100,9 @@ export type ParsedSlashCommand =
95
100
  | { type: "quit" }
96
101
  | { type: "model" }
97
102
  | { type: "model_switch"; profileName: string }
103
+ | { type: "reasoning_status" }
104
+ | { type: "reasoning_reset" }
105
+ | { type: "reasoning_set"; effort: string }
98
106
  | { type: "resume_list" }
99
107
  | { type: "resume"; sessionId: SessionId }
100
108
  | { type: "session_delete"; sessionId: SessionId };
@@ -193,6 +201,18 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
193
201
  }
194
202
  throw slashCommandUsageError("model");
195
203
  }
204
+ if (command === "/reasoning") {
205
+ if (tokens.length === 1) {
206
+ return { type: "reasoning_status" };
207
+ }
208
+ if (tokens.length === 2 && tokens[1] === "reset") {
209
+ return { type: "reasoning_reset" };
210
+ }
211
+ if (tokens.length === 2) {
212
+ return { type: "reasoning_set", effort: tokens[1] };
213
+ }
214
+ throw slashCommandUsageError("reasoning");
215
+ }
196
216
  if (command === "/resume") {
197
217
  if (tokens.length === 1) {
198
218
  return { type: "resume_list" };
@@ -18,6 +18,7 @@ import type { SessionId } from "../ids/runtime-id";
18
18
  import { createUuidV7 } from "../ids/uuid-v7";
19
19
  import type { ModelProfile } from "../cli/model-profiles";
20
20
  import type { McpInventorySnapshot } from "../mcp/mcp-manager";
21
+ import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
21
22
  import { SessionCatalog, type SessionSummary } from "../session/session-catalog";
22
23
  import type { TuiProjectionStore } from "./tui-projection-store";
23
24
 
@@ -29,6 +30,9 @@ export type TuiSessionBinding = {
29
30
  projectionStore: TuiProjectionStore;
30
31
  skills(): RuntimeSkillsSnapshot;
31
32
  mcp(): McpInventorySnapshot;
33
+ reasoningEffort?: () => ReasoningEffortSnapshot | undefined;
34
+ setReasoningEffort?: (effort: string) => ReasoningEffortSnapshot;
35
+ resetReasoningEffort?: () => ReasoningEffortSnapshot;
32
36
  supportsImageInput?: () => boolean;
33
37
  importImage?: (
34
38
  sourcePath: string,
@@ -227,6 +231,9 @@ export function managedTuiBinding(input: {
227
231
  runtimeSession: input.runtimeSession,
228
232
  skills: () => input.runtimeSession.skills(),
229
233
  mcp: () => input.runtimeSession.mcp(),
234
+ reasoningEffort: () => input.runtimeSession.reasoningEffort(),
235
+ setReasoningEffort: (effort) => input.runtimeSession.setReasoningEffort(effort),
236
+ resetReasoningEffort: () => input.runtimeSession.resetReasoningEffort(),
230
237
  supportsImageInput: () => input.runtimeSession.supportsImageInput(),
231
238
  importImage: (sourcePath, signal, prospectiveMessageImageCount) =>
232
239
  input.runtimeSession.importImage(