feat(ux): tool-row elapsed + args · reasoning tail · /think (0.4.2)

Three small improvements surfaced from real-session feedback:

1. Tool-running row was just a spinner + raw-JSON args; user said
   "看不到任何的输出,就只知道再转圈". Added elapsed seconds counter
   and per-tool argument summarizer. Instead of
     tool<filesystem_edit_file> running... {"path":"..."...}
   you now see
     tool<filesystem_edit_file> running... 3s
       path: F:\testtest\index.html (2 edits)
   Summarizers for read_file / write_file / edit_file /
   list_directory / directory_tree / search_files / move_file /
   get_file_info, matching by suffix so bridgeMcpTools name prefix
   (e.g. "filesystem_") works too. Unknown tools fall back to a
   truncated raw preview.

2. ReasoningBlock showed the first 220 chars with "(+N chars)"
   elided at the end. R1 opens every turn with the same scaffolding
   ("let me look at the structure...") so the preview looked
   identical across turns and hid the actual decision in the
   truncated tail. Switched to showing the LAST ~260 chars —
   which is where R1 commits to an action.

3. /think slash command dumps the full reasoning text from
   loop.scratch.reasoning when the tail preview isn't enough.
   Alias: /reasoning. Graceful "no reasoning cached" message when
   the current model is deepseek-chat (no reasoning_content) or a
   turn hasn't completed yet.

Tests: +3 in slash.test.ts (/think empty + populated + help).
Suite: 319 -> 322.

Bumps to 0.4.2.
This commit is contained in:
esengine committed 2026-04-21 21:05:44 -07:00
1 parent e472f4cb79
commit cb2f2a550b
7 files changed
+170 -14

No files matched your search

+48
View File
@@ -3,6 +3,54 @@
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.2] — 2026-04-21
**Headline:** Three small but visible UX improvements from a real
session: tool-call spinner now shows elapsed time + meaningful args
(not raw JSON), reasoning preview shows the *tail* instead of the
head (where the decision actually lives), and a `/think` slash
command dumps the full R1 reasoning for the most recent turn.
### Changed
- **Tool-running row surfaces elapsed seconds + per-tool argument
summary.** Instead of `⠋ tool<filesystem_edit_file> running…
{"path":"F:\\testtest\\index.html","edits":[…]}`, you now see:
```
⠋ tool<filesystem_edit_file> running… 3s
path: F:\testtest\index.html (2 edits)
```
Per-tool summarizers for `read_file`, `write_file`, `edit_file`,
`list_directory`, `directory_tree`, `search_files`, `move_file`,
`get_file_info`. Matches on suffix (`_read_file`) so namespaced
servers (`filesystem_read_file`) and anonymous servers both work.
Unknown tools fall back to a truncated raw-JSON preview — better
than nothing.
- **Reasoning preview shows the tail, not the head.** R1 opens every
turn with the same "let me look at the structure…" scaffolding, so
previously the `↳ thinking: …` line repeated across turns and hid
the real content in `(+N chars)`. Now the preview window shows the
last ~260 chars — which is where the model actually decides what
to do next. Users reported the head-only preview made R1 turns
look identical; this fixes the underlying information-hiding bug.
### Added
- **`/think` slash command.** Dumps the full raw reasoning text from
the most recent turn (read from `loop.scratch.reasoning`). Intended
for when the 260-char tail isn't enough and you want to see R1's
actual chain. Reports a helpful message if no reasoning is cached
(e.g. the current model is `deepseek-chat`, which doesn't produce
`reasoning_content`). Also listed as an alias `/reasoning`.
### Tests (+3, suite 319→322)
- `tests/slash.test.ts` (+3) — `/think` with empty scratch prints
"no reasoning cached", with populated scratch dumps the content,
help listing includes it.
---
## [0.4.1] — 2026-04-21
**Headline:** `reasonix code` grows `/undo`, `/commit`, `.gitignore`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.4.1",
"version": "0.4.2",
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
"type": "module",
"bin": {
+81 -10
View File
@@ -436,27 +436,98 @@ export function App({
* like the app has frozen — the streaming assistant display is
* already cleared and the input is disabled, so there's nothing to
* look at.
*
* We show three signals: a braille spinner (liveness), an elapsed
* timer in seconds (so "long" has a number attached), and a
* per-tool summary of the most informative argument fields (path,
* edits count, pattern, etc.). MCP doesn't stream progress today —
* when it does, this component is where the progress notifications
* would land.
*/
function OngoingToolRow({ tool }: { tool: { name: string; args?: string } }) {
const [tick, setTick] = useState(0);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 120);
return () => clearInterval(id);
const start = Date.now();
const frameId = setInterval(() => setTick((t) => t + 1), 120);
const secId = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
return () => {
clearInterval(frameId);
clearInterval(secId);
};
}, []);
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const argsPreview =
tool.args && tool.args.length > 0 && tool.args !== "{}"
? ` ${tool.args.length > 60 ? `${tool.args.slice(0, 60)}…` : tool.args}`
: "";
const summary = summarizeToolArgs(tool.name, tool.args);
return (
<Box marginY={1}>
<Text color="cyan">{frames[tick % frames.length]}</Text>
<Text color="yellow">{` tool<${tool.name}> running…`}</Text>
{argsPreview ? <Text dimColor>{argsPreview}</Text> : null}
<Box marginY={1} flexDirection="column">
<Box>
<Text color="cyan">{frames[tick % frames.length]}</Text>
<Text color="yellow">{` tool<${tool.name}> running…`}</Text>
<Text dimColor>{` ${elapsed}s`}</Text>
</Box>
{summary ? (
<Box paddingLeft={2}>
<Text dimColor>{summary}</Text>
</Box>
) : null}
</Box>
);
}
/**
* Turn raw JSON tool arguments into a one-line human summary. For
* common filesystem MCP tools we pull the actually-useful fields; for
* anything else we fall back to a truncated raw string so the user
* still sees *something* beyond the tool name.
*
* Match on suffix (e.g. `_read_file`) rather than exact name because
* `bridgeMcpTools({ namePrefix: "filesystem_" })` prepends the server
* namespace — tools arrive as `filesystem_read_file` in practice but
* callers might wire up anonymous too.
*/
function summarizeToolArgs(name: string, args?: string): string {
if (!args || args === "{}") return "";
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(args) as Record<string, unknown>;
} catch {
// Unparseable JSON — show a head slice so user at least sees
// what the model tried.
return args.length > 80 ? `${args.slice(0, 80)}…` : args;
}
const hasSuffix = (s: string) => name === s || name.endsWith(`_${s}`);
const path = typeof parsed.path === "string" ? parsed.path : undefined;
if (hasSuffix("read_file")) {
const head = typeof parsed.head === "number" ? `, head=${parsed.head}` : "";
const tail = typeof parsed.tail === "number" ? `, tail=${parsed.tail}` : "";
return `path: ${path ?? "?"}${head}${tail}`;
}
if (hasSuffix("write_file")) {
const content = typeof parsed.content === "string" ? parsed.content : "";
return `path: ${path ?? "?"} (${content.length} chars)`;
}
if (hasSuffix("edit_file")) {
const edits = Array.isArray(parsed.edits) ? parsed.edits.length : 0;
return `path: ${path ?? "?"} (${edits} edit${edits === 1 ? "" : "s"})`;
}
if (hasSuffix("list_directory") || hasSuffix("directory_tree")) {
return `path: ${path ?? "?"}`;
}
if (hasSuffix("search_files")) {
const pattern = typeof parsed.pattern === "string" ? parsed.pattern : "?";
return `path: ${path ?? "?"} · pattern: ${pattern}`;
}
if (hasSuffix("move_file")) {
const src = typeof parsed.source === "string" ? parsed.source : "?";
const dst = typeof parsed.destination === "string" ? parsed.destination : "?";
return `${src} → ${dst}`;
}
if (hasSuffix("get_file_info")) {
return `path: ${path ?? "?"}`;
}
return args.length > 80 ? `${args.slice(0, 80)}…` : args;
}
function CommandStrip({ codeMode }: { codeMode: boolean }) {
return (
<Box paddingX={2} flexDirection="column">
+7 -2
View File
@@ -114,10 +114,15 @@ function BranchBlock({ branch }: { branch: BranchSummary }) {
}
function ReasoningBlock({ reasoning }: { reasoning: string }) {
const max = 220;
const max = 260;
const flat = reasoning.replace(/\s+/g, " ").trim();
// Show the TAIL of the reasoning rather than the head. R1 opens
// with generic scaffolding ("let me look at the structure...") that
// repeats across turns and hides the part users actually want to
// see — the decision right before the model commits to an action.
// Users can dump the full reasoning with `/think` if needed.
const preview =
flat.length <= max ? flat : `${flat.slice(0, max)}… (+${flat.length - max} chars)`;
flat.length <= max ? flat : `… (+${flat.length - max} earlier chars) ${flat.slice(-max)}`;
return (
<Box marginBottom={1}>
<Text dimColor italic>
+14
View File
@@ -87,6 +87,7 @@ export function handleSlash(
" /mcp list MCP servers + tools attached to this session",
" /setup (exit + reconfigure) → run `reasonix setup`",
" /compact [cap] shrink large tool results in history (default 4k/result)",
" /think dump the most recent turn's full R1 reasoning (reasoner only)",
" /apply (code mode) commit the pending edit blocks to disk",
" /discard (code mode) drop pending edits without writing",
" /undo (code mode) roll back the last applied edit batch",
@@ -139,6 +140,19 @@ export function handleSlash(
"`reasonix setup`. Changes take effect on next launch.",
};
case "think":
case "reasoning": {
const raw = loop.scratch.reasoning;
if (!raw || !raw.trim()) {
return {
info:
"no reasoning cached. `/think` shows the full R1 thought for the most recent turn — " +
"only `deepseek-reasoner` produces it, and only once the turn completes.",
};
}
return { info: `↳ full thinking (${raw.length} chars):\n\n${raw.trim()}` };
}
case "undo": {
if (!ctx.codeUndo) {
return {
+1 -1
View File
@@ -156,4 +156,4 @@ export type {
JSONSchema,
} from "./types.js";
export const VERSION = "0.4.1";
export const VERSION = "0.4.2";
+18
View File
@@ -286,6 +286,24 @@ describe("handleSlash", () => {
expect(r.info).toMatch(/\/discard/);
});
it("/think says no reasoning cached when scratch is empty", () => {
const r = handleSlash("think", [], makeLoop());
expect(r.info).toMatch(/no reasoning cached/);
});
it("/think dumps the full reasoning when scratch has content", () => {
const loop = makeLoop();
loop.scratch.reasoning = "lots of R1 deliberation here over many sentences";
const r = handleSlash("think", [], loop);
expect(r.info).toMatch(/full thinking/);
expect(r.info).toContain("lots of R1 deliberation");
});
it("/help mentions /think", () => {
const r = handleSlash("help", [], makeLoop());
expect(r.info).toMatch(/\/think/);
});
it("/commit strips surrounding double quotes from the message", () => {
// We can't exercise git without a real repo; instead, rely on the
// fact that /commit fails (no git repo at /nonexistent) but the