Compare commits
3
Commits
5af5d7a96b
...
27e28b0dd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e28b0dd1 | ||
|
|
4e39be860c | ||
|
|
e21a4c24f0 |
No files matched your search
+40
-5
@@ -10,9 +10,16 @@
|
||||
# start time, so choosing an agent in Settings takes effect on the next crash
|
||||
# without restarting anything:
|
||||
#
|
||||
# preferredAgent none | claude | codex ("none" is the default: silence)
|
||||
# preferredAgent none | claude | codex | t3 ("none" is the default: silence)
|
||||
# agentAutoApprove true -> the agent starts in its own "don't stop to ask"
|
||||
# mode; false -> its normal prompting mode, untouched.
|
||||
# T3 Code ignores it: a T3 thread runs in the mode its
|
||||
# project is set to, which is a choice made inside T3.
|
||||
#
|
||||
# claude and codex open in a kitty window. t3 starts a thread in the Panama
|
||||
# project on the running T3 Code server and brings it up in the desktop app,
|
||||
# so the investigation lives beside every other thread instead of in a
|
||||
# terminal of its own.
|
||||
#
|
||||
# "none" exits 0 without a word. It is not an error to have no agent; it is the
|
||||
# shipped state, and a rung that shouted about it would be a rung that gets
|
||||
@@ -95,8 +102,9 @@ usage() {
|
||||
cat <<'EOF'
|
||||
Usage: panama-agent [--prompt "text"]
|
||||
|
||||
Opens the agent named by preferredAgent in a terminal, in the Panama checkout.
|
||||
With no agent chosen, exits silently: choose one on Settings > System > Agents.
|
||||
Opens the agent named by preferredAgent in the Panama checkout: a terminal for
|
||||
claude and codex, a thread in the T3 Code app for t3. With no agent chosen,
|
||||
exits silently: choose one on Settings > System > Agents.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -139,7 +147,7 @@ agent="$(read_setting preferredAgent none)"
|
||||
auto_approve="$(read_setting agentAutoApprove true)"
|
||||
|
||||
case "$agent" in
|
||||
claude | codex) ;;
|
||||
claude | codex | t3) ;;
|
||||
*)
|
||||
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
|
||||
printf 'Choose one on Settings > System > Agents.\n' >&2
|
||||
@@ -148,7 +156,8 @@ case "$agent" in
|
||||
esac
|
||||
|
||||
# Resolved to a path before argv is built, so kitty is never asked to repeat a
|
||||
# PATH lookup this script has already done more carefully than kitty could.
|
||||
# PATH lookup this script has already done more carefully than kitty could. T3
|
||||
# Code's CLI is `t3`, which the desktop app writes into ~/.local/bin.
|
||||
if ! agent_bin="$(resolve_agent "$agent")"; then
|
||||
printf 'panama-agent: %s is not installed.\n' "$agent" >&2
|
||||
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2
|
||||
@@ -156,6 +165,32 @@ if ! agent_bin="$(resolve_agent "$agent")"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T3 Code: no terminal. The thread starts on the running server in the
|
||||
# project whose root is the checkout, with the project's default model, and
|
||||
# the t3code:// link opens it in the app. `project add` refuses a root that is
|
||||
# already a project, which is the common case, so its failure is not one. The
|
||||
# prompt goes over stdin ("-"), so a prompt that begins with a dash is never
|
||||
# read as a flag. With no prompt there is nothing to start, so the app just
|
||||
# opens on the project.
|
||||
if [[ "$agent" == "t3" ]]; then
|
||||
repair_path
|
||||
"$agent_bin" project add "$PANAMA_PATH" >/dev/null 2>&1 || true
|
||||
if [[ -z "$prompt" ]]; then
|
||||
exec setsid "$agent_bin" app "$PANAMA_PATH"
|
||||
fi
|
||||
if ! thread="$(printf '%s' "$prompt" | "$agent_bin" thread start "$PANAMA_PATH" -)"; then
|
||||
printf 'panama-agent: T3 Code could not start a thread.\n' >&2
|
||||
printf 'Is the T3 Code server running? Check: systemctl --user status t3code\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
environment_file="${T3CODE_HOME:-$HOME/.t3}/userdata/environment-id"
|
||||
environment="$(cat "$environment_file" 2>/dev/null || true)"
|
||||
if [[ -n "$environment" ]]; then
|
||||
exec setsid xdg-open "t3code://threads/$environment/$thread"
|
||||
fi
|
||||
exec setsid "$agent_bin" app "$PANAMA_PATH"
|
||||
fi
|
||||
|
||||
# The per-agent launch table. VERIFIED against the installed binaries' --help on
|
||||
# 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same
|
||||
# from release to release, so re-run --help before changing them.
|
||||
|
||||
@@ -184,7 +184,8 @@ Singleton {
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "claude", label: "Claude Code" },
|
||||
{ value: "codex", label: "Codex" }
|
||||
{ value: "codex", label: "Codex" },
|
||||
{ value: "t3", label: "T3 Code" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -205,7 +206,7 @@ Singleton {
|
||||
{
|
||||
key: "agentAutoApprove", type: "bool", def: true, group: "agents",
|
||||
label: "Launched agents approve their own tools",
|
||||
detail: "Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
|
||||
detail: "Investigations run without permission prompts. T3 Code threads use the mode set on the Panama project in T3 instead. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
|
||||
},
|
||||
{
|
||||
key: "agentUsageClaude", type: "bool", def: true, group: "agents",
|
||||
|
||||
@@ -37,8 +37,8 @@ SettingsPage {
|
||||
// them not to believe the page.
|
||||
//
|
||||
// Probed through a LOGIN shell rather than this one. Quickshell is started
|
||||
// by systemd, whose PATH does not include ~/.local/bin -- where both of
|
||||
// these usually land -- and a login shell is the environment the launcher
|
||||
// by systemd, whose PATH does not include ~/.local/bin -- where all three of
|
||||
// these usually land (T3 Code's CLI is `t3`) -- and a login shell is the environment the launcher
|
||||
// hands the agent when it spawns a terminal. Asking with the shell's own
|
||||
// PATH would report "not installed" for an agent that starts perfectly.
|
||||
property var installed: ({})
|
||||
@@ -60,7 +60,7 @@ SettingsPage {
|
||||
|
||||
running: true
|
||||
command: ["bash", "-lc",
|
||||
"for agent in claude codex; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
|
||||
"for agent in claude codex t3; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.absorbProbe(this.text)
|
||||
}
|
||||
@@ -70,6 +70,7 @@ SettingsPage {
|
||||
switch (value) {
|
||||
case "claude": return "starred-symbolic";
|
||||
case "codex": return "utilities-terminal-symbolic";
|
||||
case "t3": return "view-dual-symbolic";
|
||||
default: return "notifications-disabled-symbolic";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +356,7 @@ Singleton {
|
||||
{ label: "AI assistant", detail: "Choose the agent this desktop hands crashes, failed reloads and red health checks to", page: "agents" },
|
||||
{ label: "Claude Code", detail: "Use Claude Code as the agent the desktop escalates to, and watch its usage", page: "agents" },
|
||||
{ label: "Codex", detail: "Use Codex as the agent the desktop escalates to, and watch its usage", page: "agents" },
|
||||
{ label: "T3 Code", detail: "Open a T3 Code thread in the Panama project when the desktop escalates something", page: "agents" },
|
||||
{ label: "Diagnose crashes", detail: "When a program dumps core, the notification carries a click that opens your AI assistant with the crash details", page: "agents" },
|
||||
{ label: "Ask the agent", detail: "A red System Health check with no repair left hands its snapshot to your AI assistant", page: "agents" },
|
||||
{ label: "Agent usage panel", detail: "Limits, resets and tokens for each agent, opened from the bar", page: "agents" },
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Agents in Settings.
|
||||
# @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "diagnose crashes"]
|
||||
# @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "t3 code"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page agents
|
||||
+2
-2
@@ -28,11 +28,11 @@ Found on **System › Agents**.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex. |
|
||||
| **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex, T3 Code. |
|
||||
| **Offer to diagnose crashes**<br>`crashDiagnoseOffer` | true | When a program dumps core, the notification carries a click that opens the preferred agent mid-investigation with the crash details in hand |
|
||||
| **Offer help when the shell fails to reload**<br>`reloadFailureOffer` | true | A broken change to the shell's own configuration offers the failing log to the agent |
|
||||
| **System Health hands off unrepairable checks**<br>`healthAgentHandoff` | true | A red check with no repair, or whose repair failed, grows an Ask-the-agent button carrying the check's snapshot |
|
||||
| **Launched agents approve their own tools**<br>`agentAutoApprove` | true | Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all |
|
||||
| **Launched agents approve their own tools**<br>`agentAutoApprove` | true | Investigations run without permission prompts. T3 Code threads use the mode set on the Panama project in T3 instead. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all |
|
||||
| **Collect Claude Code usage**<br>`agentUsageClaude` | true | Limits from Anthropic's usage endpoint, tokens from the local transcripts |
|
||||
| **Collect Codex usage**<br>`agentUsageCodex` | true | Limits over the Codex app-server, sessions from its local files |
|
||||
| **Refresh interval**<br>`agentUsageRefreshMinutes` | 15 min | How often the usage collectors ask for fresh numbers, in minutes. Range 5–60. |
|
||||
|
||||
@@ -86,6 +86,10 @@ rbw
|
||||
# The Snapshots page is snapper end to end; see scripts/panama-snapshots.
|
||||
snapper
|
||||
sushi
|
||||
# T3 Code, from Terra: the desktop app, its background server and the `t3` CLI.
|
||||
# panama-agent opens a T3 thread when preferredAgent is t3, and the ticket
|
||||
# skill renders its plans and mocks into T3 threads.
|
||||
t3code
|
||||
# Fonts the desktop names by family and would otherwise render as tofu.
|
||||
# VictorMono is required: it draws every glyph in the shell.
|
||||
victormono-nerd-fonts
|
||||
|
||||
@@ -27,8 +27,11 @@
|
||||
# 6. panama-agent-crash carries all four coredump facts and the absolute path
|
||||
# to the skill. The path is what makes the ladder work for an agent whose
|
||||
# harness has no skill mechanism.
|
||||
# 7. T3 Code opens no terminal. It starts a thread in the checkout's project
|
||||
# with the prompt intact on stdin, and opens that thread's t3code:// link.
|
||||
#
|
||||
# Hermetic on purpose: kitty, claude, codex, setsid and coredumpctl are all
|
||||
# Hermetic on purpose: kitty, claude, codex, t3, xdg-open, setsid and
|
||||
# coredumpctl are all
|
||||
# stubbed onto PATH and the settings file is fabricated, so no agent is ever
|
||||
# launched and no window is ever opened. This is NOT in tests/desktop-hijacking
|
||||
# for exactly that reason -- it must stay runnable mid-session.
|
||||
@@ -79,6 +82,23 @@ for agent in claude codex; do
|
||||
STUB
|
||||
done
|
||||
|
||||
# T3 Code's CLI. It records each call and its stdin, and answers `thread start`
|
||||
# with a thread id the way the real one does.
|
||||
cat >"$stub/t3" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\0' "\$@" >>"$work/t3-calls"
|
||||
printf '\n' >>"$work/t3-calls"
|
||||
if [[ "\$1 \$2" == "thread start" ]]; then
|
||||
cat >"$work/t3-stdin"
|
||||
printf 'thread-123\n'
|
||||
fi
|
||||
STUB
|
||||
|
||||
cat >"$stub/xdg-open" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '%s' "\$1" >"$work/opened"
|
||||
STUB
|
||||
|
||||
# A hand-run PID has no core here; the real one would be a slow lookup against
|
||||
# this machine's journal.
|
||||
cat >"$stub/coredumpctl" <<'STUB'
|
||||
@@ -104,7 +124,9 @@ chmod +x "$stub"/*
|
||||
# from the previous run are cleared first, so "was not launched" is a missing
|
||||
# file rather than a stale one.
|
||||
launch() {
|
||||
rm -f "$work/argv" "$work/cwd" "$work/setsid-used" "$work/agent-actually-ran"
|
||||
rm -f "$work/argv" "$work/cwd" "$work/setsid-used" "$work/agent-actually-ran" \
|
||||
"$work/t3-calls" "$work/t3-stdin" "$work/opened"
|
||||
T3CODE_HOME="$work/t3home" \
|
||||
PATH="$stub:/usr/bin:/bin" \
|
||||
PANAMA_PATH="$repo_dir" \
|
||||
PANAMA_AGENT_SETTINGS="$settings" \
|
||||
@@ -252,6 +274,35 @@ else
|
||||
note 'launching codex with auto-approve off opened no terminal'
|
||||
fi
|
||||
|
||||
# ── 7. T3 Code ──────────────────────────────────────────────────────────────
|
||||
|
||||
# The recorded t3 calls, one per line, arguments joined by \x1f.
|
||||
t3_calls() { tr '\0' '\037' <"$work/t3-calls" 2>/dev/null; }
|
||||
|
||||
mkdir -p "$work/t3home/userdata"
|
||||
printf 'env-456' >"$work/t3home/userdata/environment-id"
|
||||
|
||||
printf '{"preferredAgent":"t3","agentAutoApprove":true}\n' >"$settings"
|
||||
dash_prompt=$'--looks-like-a-flag\nsecond line'
|
||||
launch "$launcher" --prompt "$dash_prompt"
|
||||
status=$?
|
||||
|
||||
(( status == 0 )) || note "panama-agent exited $status launching T3 Code: $(head -1 "$work/stderr")"
|
||||
[[ -e "$work/argv" ]] && note 'T3 Code was launched in a terminal; it should open a thread in the app'
|
||||
t3_calls | grep -qxF "thread"$'\x1f'"start"$'\x1f'"$repo_dir"$'\x1f'"-"$'\x1f' \
|
||||
|| note 'T3 Code was not asked to start a thread in the checkout with the prompt on stdin'
|
||||
[[ "$(cat "$work/t3-stdin" 2>/dev/null)" == "$dash_prompt" ]] \
|
||||
|| note 'the T3 thread did not receive the prompt intact on stdin'
|
||||
[[ "$(cat "$work/opened" 2>/dev/null)" == "t3code://threads/env-456/thread-123" ]] \
|
||||
|| note "T3 Code opened '$(cat "$work/opened" 2>/dev/null)' rather than the new thread's link"
|
||||
|
||||
# No prompt means nothing to start: the app opens on the project.
|
||||
launch "$launcher"
|
||||
t3_calls | grep -qF "thread"$'\x1f'"start" \
|
||||
&& note 'with no prompt T3 Code still started a thread'
|
||||
t3_calls | grep -qxF "app"$'\x1f'"$repo_dir"$'\x1f' \
|
||||
|| note 'with no prompt T3 Code was not opened on the checkout'
|
||||
|
||||
# ── An agent installed where only the user's own PATH looks ─────────────────
|
||||
#
|
||||
# Both agents install themselves into ~/.local/bin. The rungs are clicked from
|
||||
|
||||
+14
-1
@@ -53,11 +53,24 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
|
||||
|
||||
# Visual & Design Work
|
||||
|
||||
- Do not edit real components first unless asked to by the user. For any non-trivial UI, layout, or copy change, build serveral distinct static mocks, publish them with a simple http server, report the URL, and stop. Wait for a decision before implementing.
|
||||
- Do not edit real components first unless asked to by the user. For any non-trivial UI, layout, or copy change, build several distinct static mocks, show them to me, and stop. Wait for a decision before implementing.
|
||||
- In T3 Code, show each mock in the thread with `html_render`, and link the mock files by absolute path so they open full size in T3's file viewer. No server.
|
||||
- Outside T3 Code, publish them with a simple http server and report the URL.
|
||||
- Avoid continuously repainting CSS animations (pulse, shimmer, blur, spinners); they peg the GPU on high-refresh displays.
|
||||
|
||||
---
|
||||
|
||||
# T3 Code
|
||||
|
||||
I run my agents in T3 Code, and I want to stay in it rather than juggle terminals and browser windows. A thread has `T3CODE_THREAD_ID` set.
|
||||
|
||||
- Show visuals in the thread: charts, tables, mocks, plans, to-do lists. `html_preview`, then `html_render`.
|
||||
- Link files by absolute path. HTML, PDF, images and Markdown open rendered in T3's file viewer, beside the thread. Do not start an http server just so I can look at a file.
|
||||
- Reach my other machines through T3's linked environments (`t3_environment_links`, then that environment's id), not through ssh, unless I ask for ssh.
|
||||
- Claude in Chrome is still the browser for my own signed-in sessions.
|
||||
|
||||
---
|
||||
|
||||
# Blast Radius
|
||||
|
||||
- Never touch production, live databases, or daily-driver build/preview channels unless explicitly asked to. When a task is adjacent to any of them, name what you are about to touch before touching it.
|
||||
|
||||
@@ -42,12 +42,3 @@ sentry-gbrown stdio bin/sentry-gbrown SENTRY_GBROWN_TOKEN
|
||||
# end-to-end runs. The ticket skill captures its proof screenshots this way.
|
||||
# No token, hence no variable in the last column.
|
||||
playwright stdio bin/playwright
|
||||
|
||||
# T3 Code environments, one per machine. Agents read projects and threads and,
|
||||
# with more than read-only access, start and message threads there. No token:
|
||||
# each is an OAuth server that asks for a pairing code from that machine on
|
||||
# first sign-in, and the grant lasts 30 days.
|
||||
t3-desktop http https://t3desktop.gbrown.org/mcp
|
||||
t3-mac http https://t3mac.gbrown.org/mcp
|
||||
t3-sr http https://t3sr.gbrown.org/mcp
|
||||
t3-jr http https://t3jr.gbrown.org/mcp
|
||||
@@ -64,7 +64,11 @@ This is the rule from my preferences, spelled out:
|
||||
way in one sentence, they are the same mock.
|
||||
2. Build them as static pages. Real content, not grey boxes labelled "image here", because
|
||||
the content is where most of the design decisions turn out to matter.
|
||||
3. Publish them with a simple http server, report the URL, and stop.
|
||||
3. Show them, and stop. In T3 Code, render each direction in the thread with `html_preview`
|
||||
then `html_render`, one direction per render so each expands full screen on its own, and
|
||||
link the files by absolute path for the file viewer. `html_render` overrides a page
|
||||
background, so a direction that paints its own goes inside an `<iframe srcdoc>`. Outside
|
||||
T3, publish them with a simple http server and report the URL.
|
||||
4. Wait for a decision before implementing anything in real components.
|
||||
|
||||
Do not edit real components first. Do not keep polishing past the point where a choice
|
||||
|
||||
@@ -121,8 +121,8 @@ Announce the path, then create a todo per item and work them in order.
|
||||
immediately rather than spending a round on the details of something that needs splitting first.
|
||||
Each sub-project earns its own design cycle.
|
||||
- **Visual questions get mocks, not prose.** Where the open question is what something should look
|
||||
like, stop describing it: build several distinct static mocks, serve them, report the URL, and wait
|
||||
for a pick. Never edit a real component to answer a layout question.
|
||||
like, stop describing it: build several distinct static mocks, show them in the thread (or serve
|
||||
them and report the URL outside T3 Code), and wait for a pick. Never edit a real component to answer a layout question.
|
||||
|
||||
## The written spec (architectural only)
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ Where the question is about logic or state rather than appearance, this is the w
|
||||
| **Static mocks** (default) | The question is layout, hierarchy, or visual direction. Standalone HTML, no app involved. | Minutes. Zero risk to the app. |
|
||||
| **In-app variants** | The question is how it behaves against **real** data, density, and chrome. A layout that looks fine on lorem ipsum falls apart on a real row count. | Higher. Touches the repo. |
|
||||
|
||||
**Start with static mocks.** They are the house default: several distinct standalone pages, served by
|
||||
a simple static server, URL handed over. Escalate to in-app variants only where real data is
|
||||
genuinely load-bearing to the judgement, and say why when you do.
|
||||
**Start with static mocks.** They are the house default: several distinct standalone pages, shown in
|
||||
the T3 Code thread (or, outside T3, served by a simple static server with the URL handed over).
|
||||
Escalate to in-app variants only where real data is genuinely load-bearing to the judgement, and
|
||||
say why when you do.
|
||||
|
||||
## Static mocks (default)
|
||||
|
||||
@@ -23,8 +24,13 @@ Write each variant as a standalone HTML file in a scratch directory outside the
|
||||
the project's design language closely enough to judge (Tailwind via CDN is fine here, since nothing
|
||||
ships).
|
||||
|
||||
Serve the directory with something already installed, such as `python3 -m http.server`, and hand over
|
||||
the URL plus the filenames. Then stop.
|
||||
In T3 Code, render each variant in the thread with `html_preview` then `html_render`, one variant per
|
||||
render, and link each file by absolute path so it opens full size in T3's file viewer. Keep each
|
||||
variant one file: the viewer gives a file outside the workspace no neighbouring styles, scripts or
|
||||
images. `html_render` overrides a page background, so pass a variant that paints its own inside an
|
||||
`<iframe srcdoc>` (the ticket skill's `ticket-page view` does this). Outside T3, serve the directory
|
||||
with something already installed, such as `python3 -m http.server`, and hand over the URL plus the
|
||||
filenames. Then stop.
|
||||
|
||||
Never edit a real component to answer a layout question.
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ sees. It is a personal working file, exempt from the prose bar like the rest of
|
||||
|
||||
```
|
||||
.claude/docs/epics/
|
||||
_site/site.css, site.js # shared renderer, refreshed from templates/site/ on every write
|
||||
<EPIC-KEY>/
|
||||
index.html, epic.data.js # the epic index, in build order
|
||||
<TICKET-KEY>/
|
||||
@@ -16,6 +15,35 @@ sees. It is a personal working file, exempt from the prose bar like the rest of
|
||||
tickets/<TICKET-KEY>/ # same shape, for a ticket with no epic, and no index
|
||||
```
|
||||
|
||||
Each `index.html` carries the renderer from `templates/site/` inline, rewritten on every
|
||||
write, and loads only its own data file beside it. That is what lets T3 Code's file viewer
|
||||
show it: an HTML file there can load files from its own folder and below, nothing above.
|
||||
|
||||
## In T3 Code
|
||||
|
||||
Gib follows the work in a T3 Code thread, so the page and the plan come to him there.
|
||||
|
||||
- **The live page.** `ticket-page open <KEY>` refreshes the page and prints its absolute path.
|
||||
Link that path in your reply, and it opens in T3's file viewer beside the thread, still
|
||||
reloading as you write. Outside T3 (no `T3CODE_THREAD_ID`) it also opens the browser.
|
||||
Never start an http server for the page.
|
||||
- **Inline snapshots.** `ticket-page view <KEY> <view>` prints a self-contained page styled
|
||||
with T3's theme. Pass its output, verbatim, to `html_preview`, check the screenshot, then
|
||||
to `html_render` with the `contentHeight` it reported (capped at 2000) as the height. Do not
|
||||
restate what the snapshot shows in your reply. Snapshots do not update, so render one at
|
||||
the moment it is for, not on every write.
|
||||
|
||||
| Moment | View |
|
||||
|---|---|
|
||||
| Plan ready for approval (Phase 1, last step) | `plan`, then `mocks --mock <id>` once per artboard |
|
||||
| Hand-off for pre-mr-review, and on resume | `progress` |
|
||||
|
||||
- One artboard per render, so each can be expanded full screen on its own and options sit
|
||||
one after another for comparison. `view` refuses a page over html_render's 512 KB and
|
||||
names the artboards to render one at a time.
|
||||
- Keep artboards light: CSS and real copy, not base64 screenshots. A screenshot pasted into an
|
||||
artboard as a data URI is most of its size, and every byte goes through the conversation.
|
||||
|
||||
## Write through `ticket-page`, always
|
||||
|
||||
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
|
||||
@@ -31,7 +59,7 @@ the repo.
|
||||
|
||||
| Moment | Command |
|
||||
|---|---|
|
||||
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. |
|
||||
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. Then `open <KEY>` and link the path. |
|
||||
| Attachments downloaded, a video transcribed, frames extracted | `scan <KEY>` |
|
||||
| Ticket branch resolved, ticket classified | `meta <KEY> --branch <name> --classified bounded` |
|
||||
| Research notes worth keeping: what a video says, what a spreadsheet holds, a check against prod | `note <KEY> "<Title>" --file notes.html` |
|
||||
|
||||
@@ -25,6 +25,11 @@ Jira has it, the plan with its mocks, live progress, proof beside the mocks, and
|
||||
Every epic gets an **epic index** listing its stories in build order. Both render from data
|
||||
files that only `ticket-page` writes, and both update themselves as it writes.
|
||||
|
||||
Gib works in T3 Code. The page opens in T3's file viewer beside the thread when you link its
|
||||
absolute path, so never start a server for it. At two moments, plan approval and hand-off,
|
||||
the skill also shows a snapshot inline in the thread with `ticket-page view`, so he can read
|
||||
it without leaving the conversation. SITE.md has the commands, under **In T3 Code**.
|
||||
|
||||
**Read [SITE.md](SITE.md) before the first write of every run.** It holds the command for each
|
||||
moment the phases below name, the plan's JSON shape, how to draw mocks, and how the epic order
|
||||
is seeded. Record each moment when it happens, not in a batch at the end, because Gib is
|
||||
@@ -34,9 +39,8 @@ watching the page change.
|
||||
|
||||
```
|
||||
.claude/docs/epics/
|
||||
_site/ # the shared renderer, refreshed by ticket-page
|
||||
<EPIC-KEY>/
|
||||
index.html, epic.data.js # the epic index
|
||||
index.html, epic.data.js # the epic index (each index.html carries its own renderer)
|
||||
<TICKET-KEY>/
|
||||
index.html, ticket.data.js # the ticket page
|
||||
resources/
|
||||
@@ -468,8 +472,8 @@ place from the start.
|
||||
epic (or `tickets/` without one), imports every rich-text field verbatim as sanitized
|
||||
HTML, writes the page, and prints the ticket folder. Copy the fetched JSON to
|
||||
`<target-dir>/resources/issue.raw.json`, which later re-imports and the attachment
|
||||
script read. Record the branch from **Branch setup** with `ticket-page meta`, then open
|
||||
the page with `ticket-page open <KEY>` so Gib can follow from here on.
|
||||
script read. Record the branch from **Branch setup** with `ticket-page meta`, then run
|
||||
`ticket-page open <KEY>` and link the path it prints, so Gib can follow from here on.
|
||||
4. `init` ends by listing populated Jira fields the page does not show. Read each one and
|
||||
add any that bear on implementing or verifying the ticket as a note. This sweep exists
|
||||
because real content sometimes hides in per-project custom fields with unpredictable
|
||||
@@ -580,7 +584,9 @@ place from the start.
|
||||
otherwise.
|
||||
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`,
|
||||
as [SITE.md](SITE.md) describes under **Mocks**.
|
||||
13. Stop here. Tell the user the plan is on the ticket page, give its path from
|
||||
13. Stop here. Show the plan in the thread: `ticket-page view <KEY> plan`, then each mock
|
||||
artboard with `ticket-page view <KEY> mocks --mock <id>`, each through `html_preview`
|
||||
and then `html_render` (SITE.md, **In T3 Code**). Then link the page path from
|
||||
`ticket-page path <KEY>`, summarize the approach in a couple of sentences, and mention
|
||||
any open questions that need their input before you'd implement it. Do not start
|
||||
implementing in this same run — wait for them to review the plan and invoke
|
||||
@@ -700,9 +706,10 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
|
||||
review run from the developer's separate work Claude account, so the audit on
|
||||
record must come from there, not from this session. Instead:
|
||||
1. Tell the user the branch is ready for its pre-mr-review and ask them to run
|
||||
`/pre-mr-review` from their work account, then let you know when it has
|
||||
finished. Stop and wait, this is a hard gate.
|
||||
1. Show where the build stands with `ticket-page view <KEY> progress`, through
|
||||
`html_preview` and then `html_render`. Then tell the user the branch is ready for
|
||||
its pre-mr-review and ask them to run `/pre-mr-review` from their work account,
|
||||
then let you know when it has finished. Stop and wait, this is a hard gate.
|
||||
2. When they say it ran, read the audit and handoff files it wrote under
|
||||
`.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review`
|
||||
skill prints the exact paths). Check the `last_reviewed_head` in the audit
|
||||
@@ -944,6 +951,7 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
|
||||
If Phase 0 routes here because some steps are already done on the page: check
|
||||
`git log` on the current branch against the page's step commits to sanity-check they actually
|
||||
match reality (a checked-off step should have a corresponding commit), then continue
|
||||
from the first open step. If the plan and the git history disagree, stop and ask
|
||||
the user rather than guessing which one is right.
|
||||
match reality (a checked-off step should have a corresponding commit), then show
|
||||
`ticket-page view <KEY> progress` in the thread and continue from the first open step.
|
||||
If the plan and the git history disagree, stop and ask the user rather than guessing
|
||||
which one is right.
|
||||
@@ -0,0 +1,125 @@
|
||||
// Thread views: compact, static HTML snapshots of a ticket for T3 Code's html_render, which
|
||||
// shows a page inline in the conversation. The live ticket page stays the full record; these
|
||||
// are what Gib reads without leaving the thread. `ticket-page view` prints one, and the skill
|
||||
// hands it to html_preview, then html_render.
|
||||
//
|
||||
// html_render's rules shape everything here: no background on html, body or the outer
|
||||
// element, colours only from T3's theme variables, no vh units, and a box with its own
|
||||
// background gets 16px padding and var(--radius) corners. No script except the one that
|
||||
// sizes each mock frame to its artboard.
|
||||
|
||||
import type { Criterion, Mock, Step, Ticket } from "./ticket-data";
|
||||
|
||||
export const VIEWS = ["plan", "progress", "mocks"] as const;
|
||||
/** html_render's limit on a page, in characters. */
|
||||
export const RENDER_LIMIT = 512_000;
|
||||
export type View = (typeof VIEWS)[number];
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
const short = (sha: string) => sha.slice(0, 8);
|
||||
|
||||
const CSS = `
|
||||
body{margin:0;font:14px/1.5 var(--font-sans);color:var(--foreground)}
|
||||
h1{font-size:16px;margin:0 0 2px}h2{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted-foreground);margin:20px 0 8px}
|
||||
p{margin:0 0 8px}code{font:12px var(--font-mono);background:var(--code-background);padding:1px 4px;border-radius:4px}
|
||||
.sub{color:var(--muted-foreground);font-size:12px}
|
||||
.ask{background:var(--warning-surface);color:var(--foreground);border-radius:var(--radius);padding:16px;margin-top:12px}
|
||||
.ask h2{margin-top:0;color:var(--warning)}.ask ol{margin:0;padding-left:20px}
|
||||
ul.rows{list-style:none;margin:0;padding:0}ul.rows li{display:flex;gap:10px;padding:6px 0;border-top:1px solid var(--border)}
|
||||
ul.rows li:first-child{border-top:0}
|
||||
.box{flex:none;width:16px;height:16px;margin-top:2px;border:1.5px solid var(--border);border-radius:4px;display:grid;place-items:center;font-size:11px;line-height:1}
|
||||
.box.done{background:var(--success);border-color:var(--success);color:var(--background)}.box.run{border-color:var(--primary);color:var(--primary)}
|
||||
.grow{flex:1;min-width:0}.tag{font-size:11px;color:var(--muted-foreground);border:1px solid var(--border);border-radius:999px;padding:0 6px;margin-left:6px;white-space:nowrap}
|
||||
.id{font-weight:600;margin-right:6px}.meta{font-size:12px;color:var(--muted-foreground)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}td,th{text-align:left;vertical-align:top;padding:6px 8px 6px 0;border-top:1px solid var(--border)}th{font-weight:600;color:var(--muted-foreground);font-size:12px}
|
||||
.bar{height:6px;border-radius:3px;background:var(--secondary);overflow:hidden;margin:4px 0 2px}.bar i{display:block;height:100%;background:var(--success)}
|
||||
.stats{display:flex;gap:24px;flex-wrap:wrap}.stats div{min-width:140px}
|
||||
.mock{margin-top:16px}.mock iframe{display:block;width:100%;height:420px;border:1px solid var(--border);border-radius:var(--radius);background:#fff}
|
||||
`;
|
||||
|
||||
// Collapses each frame before measuring it: an iframe reports its own viewport height as
|
||||
// the document's scrollHeight, so measuring at 420px never reads less than 420.
|
||||
const MOCK_SIZER = `<script>
|
||||
for (const f of document.querySelectorAll('iframe[data-fit]')) f.addEventListener('load', () => {
|
||||
try { f.style.height = '0px'; f.style.height = f.contentDocument.documentElement.scrollHeight + 2 + 'px'; }
|
||||
catch { f.style.height = ''; }
|
||||
});
|
||||
</script>`;
|
||||
|
||||
function page(title: string, body: string, extra = "") {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>${CSS}</style></head><body>${body}${extra}</body></html>`;
|
||||
}
|
||||
|
||||
const box = (status: string) =>
|
||||
`<span class="box${status === "done" ? " done" : status === "running" ? " run" : ""}">${status === "done" ? "✓" : status === "running" ? "•" : ""}</span>`;
|
||||
|
||||
const head = (t: Ticket, what: string) =>
|
||||
`<h1>${esc(t.key)}: ${esc(t.title)}</h1><div class="sub">${esc(what)}${t.branch ? ` on <code>${esc(t.branch)}</code>` : ""}</div>`;
|
||||
|
||||
const stepRow = (s: Step) =>
|
||||
`<li>${box(s.status)}<div class="grow">${s.n}. ${esc(s.text)}${s.kind !== "code" ? `<span class="tag">${s.kind}</span>` : ""}${s.tdd ? `<span class="tag">tests first</span>` : ""}` +
|
||||
`${s.commits.length ? `<div class="meta">${s.commits.map(c => `<code>${esc(short(c))}</code>`).join(" ")}</div>` : ""}</div></li>`;
|
||||
|
||||
const criterionRow = (t: Ticket, c: Criterion) => {
|
||||
const proof = t.proof.filter(p => p.proves.includes(c.id)).length;
|
||||
const extra = [`steps ${c.steps.join(", ")}`, proof ? `${proof} proof` : "", c.evidence ? esc(c.evidence) : ""].filter(Boolean).join(" · ");
|
||||
return `<li>${box(c.status)}<div class="grow"><span class="id">${esc(c.id)}</span>${esc(c.text)}<div class="meta">${extra}</div></div></li>`;
|
||||
};
|
||||
|
||||
const pct = (a: number, b: number) => (b ? Math.round((100 * a) / b) : 0);
|
||||
|
||||
/** The plan as it stands for approval: questions first, because they are what blocks it. */
|
||||
function plan(t: Ticket): string {
|
||||
const pl = t.plan!;
|
||||
const parts = [head(t, pl.approvedAt ? "Plan, approved" : "Plan, awaiting approval")];
|
||||
if (pl.questions.length) parts.push(`<div class="ask"><h2>Needs your answer</h2><ol>${pl.questions.map(q => `<li>${q}</li>`).join("")}</ol></div>`);
|
||||
parts.push(`<h2>Approach</h2>${pl.approach}`);
|
||||
parts.push(`<h2>Acceptance criteria</h2><ul class="rows">${pl.criteria.map(c => criterionRow(t, c)).join("")}</ul>`);
|
||||
parts.push(`<h2>Steps</h2><ul class="rows">${pl.steps.map(stepRow).join("")}</ul>`);
|
||||
if (pl.decisions.length)
|
||||
parts.push(`<h2>Decisions</h2><table><tr><th>Question</th><th>Choice</th><th>Why</th></tr>${pl.decisions.map(([q, c, w]) => `<tr><td>${esc(q)}</td><td>${esc(c)}</td><td>${esc(w)}</td></tr>`).join("")}</table>`);
|
||||
if (pl.risks.length)
|
||||
parts.push(`<h2>Risks</h2><table><tr><th>Risk</th><th>Handling</th><th>Covers</th></tr>${pl.risks.map(r => `<tr><td>${esc(r.risk)}${r.fromTicket ? `<span class="tag">ticket</span>` : ""}</td><td>${esc(r.handling)}</td><td>${esc((r.criteria ?? []).join(", "))}</td></tr>`).join("")}</table>`);
|
||||
if (pl.tests.length)
|
||||
parts.push(`<h2>Test plan</h2><table><tr><th>Kind</th><th>Test</th><th>Covers</th></tr>${pl.tests.map(x => `<tr><td>${esc(x.kind)}</td><td>${esc(x.text)}${x.fromTicket ? `<span class="tag">ticket</span>` : ""}</td><td>${esc((x.criteria ?? []).join(", "))}</td></tr>`).join("")}</table>`);
|
||||
if (pl.mocks.length) parts.push(`<p class="sub">${pl.mocks.length} mock artboard${pl.mocks.length > 1 ? "s" : ""}: ${pl.mocks.map(m => esc(m.title)).join(", ")}.</p>`);
|
||||
return page(`${t.key} plan`, parts.join(""));
|
||||
}
|
||||
|
||||
/** The to-do list: steps and criteria with their marks, then what review still holds open. */
|
||||
function progressView(t: Ticket): string {
|
||||
const pl = t.plan!;
|
||||
const steps = pl.steps.filter(s => s.status === "done").length;
|
||||
const crit = pl.criteria.filter(c => c.status === "done").length;
|
||||
const open = t.review.findings.filter(f => f.status === "open");
|
||||
const stat = (label: string, a: number, b: number) => `<div><div class="meta">${label}</div><div class="bar"><i style="width:${pct(a, b)}%"></i></div><b>${a}</b> of ${b}</div>`;
|
||||
const parts = [head(t, "Progress"), `<div class="stats" style="margin-top:12px">${stat("Steps", steps, pl.steps.length)}${stat("Criteria", crit, pl.criteria.length)}<div><div class="meta">Proof</div><b>${t.proof.length}</b> captured</div></div>`];
|
||||
parts.push(`<h2>Steps</h2><ul class="rows">${pl.steps.map(stepRow).join("")}</ul>`);
|
||||
parts.push(`<h2>Acceptance criteria</h2><ul class="rows">${pl.criteria.map(c => criterionRow(t, c)).join("")}</ul>`);
|
||||
if (open.length) parts.push(`<h2>Open review findings</h2><ul class="rows">${open.map(f => `<li>${box("todo")}<div class="grow"><span class="id">${esc(f.id)}</span>${esc(f.text)}${f.where ? `<div class="meta"><code>${esc(f.where)}</code></div>` : ""}</div></li>`).join("")}</ul>`);
|
||||
const v = t.review.preMr;
|
||||
if (v) parts.push(`<h2>pre-mr-review</h2><p><b>${esc(v.verdict)}</b>${v.head ? ` at <code>${esc(short(v.head))}</code>` : ""}, run ${v.runs}</p>`);
|
||||
return page(`${t.key} progress`, parts.join(""));
|
||||
}
|
||||
|
||||
/** Each artboard in its own frame, sized to its content, under its title, criteria and note. */
|
||||
function mocks(t: Ticket, only?: string): string {
|
||||
const all = t.plan!.mocks;
|
||||
const list: Mock[] = only ? [all.find(m => m.id === only) ?? fail(`no mock ${only}. Mocks: ${all.map(m => m.id).join(", ") || "none"}`)] : all;
|
||||
if (!list.length) fail(`${t.key} has no mocks`);
|
||||
const body = list.map(m =>
|
||||
`<div class="mock"><b>${esc(m.title)}</b> <span class="sub">${esc(m.id)}${m.criteria.length ? ` · ${esc(m.criteria.join(", "))}` : ""}</span>` +
|
||||
`${m.note ? `<div class="meta">${esc(m.note)}</div>` : ""}<iframe data-fit sandbox="allow-same-origin" srcdoc="${esc(m.html)}" title="${esc(m.title)}"></iframe></div>`).join("");
|
||||
return page(`${t.key} mocks`, head(t, "Mocks") + body, MOCK_SIZER);
|
||||
}
|
||||
|
||||
function fail(msg: string): never { throw new Error(msg); }
|
||||
|
||||
export function renderView(t: Ticket, view: View, mock?: string): string {
|
||||
if (!t.plan) fail(`${t.key} has no plan yet, so there is nothing to show`);
|
||||
const html = view === "plan" ? plan(t) : view === "progress" ? progressView(t) : mocks(t, mock);
|
||||
if (html.length > RENDER_LIMIT)
|
||||
fail(`the ${view} view is ${Math.round(html.length / 1000)} KB, over html_render's ${RENDER_LIMIT / 1000} KB.` +
|
||||
(view === "mocks" && !mock ? ` Show one artboard at a time with --mock: ${t.plan.mocks.map(m => m.id).join(", ")}.` : " Link the ticket page instead."));
|
||||
return html;
|
||||
}
|
||||
@@ -67,7 +67,10 @@ describe("the CLI, end to end", () => {
|
||||
expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0);
|
||||
for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true);
|
||||
expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true);
|
||||
expect(readFileSync(join(dir, "index.html"), "utf8")).toMatch(/href="..\/..\/_site\/site.css\?v=[0-9a-f]{10}"/);
|
||||
// Self-contained: T3 Code's file viewer serves nothing from above the page's own folder.
|
||||
const page = readFileSync(join(dir, "index.html"), "utf8");
|
||||
expect(page).toContain(readFileSync(join(import.meta.dir, "../templates/site/site.js"), "utf8"));
|
||||
expect(page).not.toMatch(/(src|href)="\.\./);
|
||||
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
|
||||
});
|
||||
|
||||
@@ -85,6 +88,15 @@ describe("the CLI, end to end", () => {
|
||||
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } });
|
||||
});
|
||||
|
||||
test("view prints a thread snapshot of the plan's progress, and refuses an unknown view", () => {
|
||||
const out = cli("view", "KACP-1", "progress");
|
||||
expect(out.status).toBe(0);
|
||||
expect(out.stdout).toMatch(/^<!doctype html>/);
|
||||
expect(out.stdout).toContain("<code>abc1234</code>");
|
||||
expect(cli("view", "KACP-1", "mocks").status).not.toBe(0);
|
||||
expect(cli("view", "KACP-1", "everything").status).toBe(2);
|
||||
});
|
||||
|
||||
test("text proof is embedded, because a file:// page cannot fetch it", () => {
|
||||
mkdirSync(join(dir, "proof"), { recursive: true });
|
||||
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored");
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
|
||||
// ticket skill calls each command. Run `ticket-page help` for the command list.
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
|
||||
import { renderView, VIEWS } from "./thread-view";
|
||||
import {
|
||||
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
|
||||
storyStatus, type Ticket, writeData,
|
||||
@@ -49,24 +49,19 @@ function ticketDir(key: string): string {
|
||||
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
|
||||
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
|
||||
|
||||
/** Copies the shared renderer into _site/ when the skill's copy changed, and writes the
|
||||
* page shell. Run on every write so an old ticket picks up the current renderer. */
|
||||
/** Writes the page shell with the renderer's CSS and JS inlined, so the page carries
|
||||
* everything but its own data file. T3 Code's file viewer serves an HTML file's assets only
|
||||
* from its own folder and below, so a shell that pointed at a shared ../../_site/ renderer
|
||||
* rendered blank there. Run on every write so an old ticket picks up the current renderer. */
|
||||
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
|
||||
const site = join(docsRoot(), "_site");
|
||||
mkdirSync(site, { recursive: true });
|
||||
const version = createHash("sha1");
|
||||
for (const f of ["site.css", "site.js"]) {
|
||||
const from = join(SITE_TEMPLATES, f), to = join(site, f);
|
||||
const body = readFileSync(from, "utf8");
|
||||
version.update(body);
|
||||
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
|
||||
}
|
||||
// The shell names the renderer by content hash, so a browser that cached an older
|
||||
// renderer loads the new one the next time the page opens.
|
||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8")
|
||||
.replaceAll("{{SITE}}", relative(pageDir, site) || ".")
|
||||
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10))
|
||||
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "<" : "&")));
|
||||
const fill: Record<string, string> = {
|
||||
CSS: readFileSync(join(SITE_TEMPLATES, "site.css"), "utf8"),
|
||||
JS: readFileSync(join(SITE_TEMPLATES, "site.js"), "utf8"),
|
||||
TITLE: title.replace(/[<&]/g, c => (c === "<" ? "<" : "&")),
|
||||
};
|
||||
// A replacer function, because the renderer's own source is full of `$` sequences that
|
||||
// a replacement string would read as patterns.
|
||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE)\}\}/g, (_, k: string) => fill[k]);
|
||||
const page = join(pageDir, "index.html");
|
||||
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
|
||||
}
|
||||
@@ -342,8 +337,24 @@ command("show", "show <KEY> print where the ticket stands", a => {
|
||||
|
||||
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
|
||||
|
||||
command("open", "open <KEY> open the page in the browser", a => {
|
||||
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
|
||||
command("open", "open <KEY> print the page path, and open it in the browser outside T3 Code", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
// Rewrites the shells first, so a page last written before the renderer was inlined
|
||||
// opens in T3 too.
|
||||
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
|
||||
const epic = t.epic && epicFile(t.epic.key);
|
||||
if (epic && existsSync(epic)) { const e = readData(epic, "epic"); refreshSite(dirname(epic), "epic", `${e.key}: ${e.title}`); }
|
||||
const page = join(dir, "index.html");
|
||||
// Inside a T3 Code thread the agent links this path and the page opens in T3's file
|
||||
// viewer, beside the conversation. A browser window would be one more thing to juggle.
|
||||
if (!process.env.T3CODE_THREAD_ID) spawnSync("xdg-open", [page], { stdio: "ignore" });
|
||||
console.log(page);
|
||||
});
|
||||
|
||||
command("view", `view <KEY> <${VIEWS.join("|")}> [--mock <id>] print a thread view for T3 Code's html_render`, a => {
|
||||
const [key, name] = a.pos;
|
||||
const view = VIEWS.find(v => v === name) ?? fail(`view takes ${VIEWS.join(", ")}`);
|
||||
console.log(renderView(load(key).t, view, a.one("mock")));
|
||||
});
|
||||
|
||||
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="epic" data-src="epic.data.js">
|
||||
<div id="app"><p class="loading">Loading epic.data.js…</p></div>
|
||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
||||
<script>
|
||||
{{JS}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Shared stylesheet for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page. */
|
||||
/* Shared stylesheet for ticket and epic pages. ticket-page inlines it into every page shell. */
|
||||
:root {
|
||||
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
|
||||
--line: #e2e2dd; --accent: #2f5fd0; --accent-ink: #fff; --accent-soft: #e8eeff;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Renderer for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page.
|
||||
// Renderer for ticket and epic pages. ticket-page inlines it into every page shell, so a
|
||||
// page needs nothing outside its own folder (T3 Code's file viewer allows nothing else).
|
||||
// A page shell names its data file in <body data-src>. This script loads that file with a
|
||||
// <script> tag (file:// pages cannot fetch), re-loads it every two seconds, and redraws
|
||||
// only when the data changed. The data shape is defined in scripts/ticket-data.ts.
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="ticket" data-src="ticket.data.js">
|
||||
<div id="app"><p class="loading">Loading ticket.data.js…</p></div>
|
||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
||||
<script>
|
||||
{{JS}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user