mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
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:
1 parent
e472f4cb79
commit
cb2f2a550b
7 files changed
+170
-14
No files matched your search
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -156,4 +156,4 @@ export type {
|
||||
JSONSchema,
|
||||
} from "./types.js";
|
||||
|
||||
export const VERSION = "0.4.1";
|
||||
export const VERSION = "0.4.2";
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user